From f5c43a23d53b8ac5b6956b4438338c90093a1581 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:46:12 +0000 Subject: [PATCH 01/10] feat(spec,rest,lint)!: retire the form field's publicPicker and delete the anonymous lookup route (WIP) Ruling E: anonymous public forms no longer take lookup / master_detail / user fields. The key becomes a retiredKey() tombstone with an ADR-0087 D2 conversion and registry entries; GET /forms/:slug/lookup/:field and its helper module are deleted; the resolve route's strip is unconditional; the lint reader and the picker tests go. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- content/docs/ui/forms.mdx | 64 +- .../areas/access-security.json | 18 +- .../src/validate-preset-comparands.test.ts | 99 +-- .../lint/src/validate-preset-comparands.ts | 82 +- .../src/protocol.save-union-issues.test.ts | 13 +- packages/metadata-protocol/src/protocol.ts | 9 +- ...ublic-picker-queryable-key.dogfood.test.ts | 157 ---- .../test/zero-set-masking.dogfood.test.ts | 80 +- ...public-form-lookup-filter-lowering.test.ts | 476 ----------- ...c-form-lookup-picker-queryable-key.test.ts | 422 ---------- .../src/public-form-lookup-picker.test.ts | 736 ------------------ .../src/public-form-routes.stored-row.test.ts | 69 +- packages/rest/src/public-form-routes.test.ts | 122 ++- packages/rest/src/rest-route-ledger.ts | 2 - .../rest-server-canonical-query-ast.test.ts | 73 +- .../rest-server-query-number-census.test.ts | 7 - packages/rest/src/rest-server.ts | 394 +--------- .../rest/src/view-filter-rule-lowering.ts | 90 --- packages/spec/liveness/view.json | 4 +- .../spec/src/api/error-code-ledger.zod.ts | 2 - packages/spec/src/conversions/registry.ts | 138 ++++ .../18.ui__FormField__publicPicker.ts | 20 + .../18.form-field-public-picker-retired.ts | 33 + packages/spec/src/migrations/registry.ts | 63 ++ ...orm-field-public-picker-retirement.test.ts | 433 +++++++++++ .../spec/src/ui/view-public-picker.test.ts | 188 ----- .../src/ui/view-union-branch-focus.test.ts | 37 +- packages/spec/src/ui/view.zod.ts | 129 +-- packages/spec/vitest.repo-tests.json | 1 + scripts/engine-double-contract.pinned.json | 30 - 30 files changed, 930 insertions(+), 3061 deletions(-) delete mode 100644 packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts delete mode 100644 packages/rest/src/public-form-lookup-filter-lowering.test.ts delete mode 100644 packages/rest/src/public-form-lookup-picker-queryable-key.test.ts delete mode 100644 packages/rest/src/public-form-lookup-picker.test.ts delete mode 100644 packages/rest/src/view-filter-rule-lowering.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__publicPicker.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.form-field-public-picker-retired.ts create mode 100644 packages/spec/src/ui/form-field-public-picker-retirement.test.ts delete mode 100644 packages/spec/src/ui/view-public-picker.test.ts diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index fb7ac67bdf1..51c2d29c154 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -234,64 +234,22 @@ Errors: The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws. -### `GET /api/v1/forms/:slug/lookup/:field` — the public picker +### Lookup, master-detail and `user` fields -Lookup, master-detail and `user` fields are **stripped from a public form by -default** — surfacing one to anonymous visitors means exposing a record search -to the internet, so it is opt-in per field, like Airtable's "Allow linking to -existing records" toggle. The opt-in is a `publicPicker` block on the field's -entry in `sections[].fields[]` (declarable since #7467): +An anonymous public form does not offer record search. `GET /api/v1/forms/:slug` +leaves every `lookup`, `master_detail` and `user` field off the rendered +`sections`, whatever the form declares — surfacing one would mean letting an +anonymous visitor search existing records on the internet. There is no anonymous +lookup route. -```ts -sections: [{ - name: 'about_you', - label: 'About you', - fields: [ - 'company', - { field: 'owner', publicPicker: { displayFields: ['name'], maxResults: 10 } }, - ], -}] -``` - -| `publicPicker` key | Meaning | -|---|---| -| `displayFields` | Fields projected into each result row (plus `id`); the visitor's `q` is `contains`-matched against the **first** entry. At most 5; omitted → `['name']`. | -| `maxResults` | Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is **no pagination** on this surface (`offset` is pinned to 0), so a leaked endpoint cannot enumerate the table. | -| `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. | -| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, or — for a field type whose target is fixed by the TYPE rather than chosen by the author (`user`, whose constant is `sys_user`) — that constant. Those two are the only sources, and a `user` field authored without `reference` is fully specified, not under-specified (#19289). A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved, because `FieldSchema` accepts no spelling but `reference`: on a `lookup` / `master_detail` the route then answers `500 LOOKUP_TARGET_MISSING`, and on a `user` field the type's own constant answers instead — the alias contributes nothing either way. That key is also **read through the one carrier accessor**, so a stored row whose `reference` holds something other than a string (an object, an array) is refused rather than searched — see the error table below. Declare it only to search something other than what the field points at. | - -Those four keys are the whole block. It admits exactly what the route enforces -— an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse -error at authoring time**, not a silently-adjusted request. - -Result **ordering is fixed**: the first `displayFields` entry, ascending. It is -not configurable — `publicPicker.sort` is an unknown subkey, and #7485 retired -the route's read of one — so a picker's rows arrive in one predictable order -whatever the target object's own default ordering is. - -```bash -curl 'http://localhost:3000/api/v1/forms/contact-us/lookup/owner?q=ada' -``` - -```json -{ "data": [{ "id": "usr_1", "name": "Ada Lovelace" }], - "total": 1, "truncated": false, "displayFields": ["name"] } -``` - -Errors: - -| Status | Code | When | -|---|---|---| -| `400 INVALID_REQUEST` | missing / blank slug or field | -| `403 LOOKUP_NOT_PUBLIC` | the field has no `publicPicker` block — the deliberate loud default (#3022); also any server-managed anchor (`owner_id`, `organization_id`, …), which never gets a picker even if one is declared | -| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view | -| `500 LOOKUP_TARGET_MISSING` | the referenced object could not be resolved from either `publicPicker.object` or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical `reference`; declaring `object` was the workaround and is no longer needed. | -| `500 INTERNAL_ERROR` | the field def **declares** a target this route cannot READ — a stored `reference` holding an object or an array rather than the object name `FieldSchema` declares. ⚠️ Deliberately **not** `LOOKUP_TARGET_MISSING`: "nothing names the target" and "the named target is unreadable" want different fixes from whoever owns the metadata, so they get different answers. The unreadable carrier is named in full in the server log (it is withheld from the response body, as every fault's text is); the picker's search never runs. | +- To let a visitor choose from a fixed list, use a `select` field with static `options`. +- 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. ### Auth model -- None of the three routes calls `enforceAuth`, so they work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17). -- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead. +- Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17). +- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. - No CSRF or auth header is needed; embed the form on any domain. ## 5. Embedding from a front-end diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 07c47c1bda8..6eba711c5dd 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1392,7 +1392,7 @@ "title": "Anonymous public form renders the whitelist only and its submit strips forged anchors; the inquiry lands via publicFormGrant", "since": "v15.1", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": [ @@ -1444,10 +1444,10 @@ "evidence": "the 201 + the landed row" }, { - "clause": "a non-whitelisted lookup picker is refused: anon GET /forms/contact-us/lookup/owner_id answers 403 LOOKUP_NOT_PUBLIC — the form cannot open an anonymous sys_user search the submit would refuse", + "clause": "there is no anonymous record search: the rendered sections carry no lookup / master_detail / user field whatever the form declares, and anon GET /forms/contact-us/lookup/owner_id answers what any unregistered path answers (404 ENDPOINT_NOT_FOUND) — the anonymous lookup-picker route was retired", "oracle": "api", - "verify": "the lookup route status 403 + code LOOKUP_NOT_PUBLIC (public-form-routes.test.ts lookup case)", - "evidence": "the lookup trace" + "verify": "the old picker path status 404 + error.code ENDPOINT_NOT_FOUND, identical to a never-registered sibling path (public-form-routes.test.ts [#21180] cases)", + "evidence": "the lookup-path trace" }, { "clause": "submitBehavior renders: after an anonymous submit the browser shows the thank-you panel (submitBehavior kind 'thank-you')", @@ -1474,8 +1474,8 @@ "examples/app-showcase/src/ui/views/inquiry.view.ts#formViews (formViews.contact: allowAnonymous, publicLink '/forms/contact-us', whitelist, submitBehavior)", "examples/app-showcase/src/data/objects/inquiry.object.ts#sharingModel (sharingModel private, publicFormGrant docblock, server-controlled status/source)", "examples/app-showcase/src/data/hooks/index.ts (guest-defaults stamping)", - "packages/rest/src/rest-route-ledger.ts (forms family: GET /forms/:slug, POST /forms/:slug/submit, GET /forms/:slug/lookup/:field)", - "packages/rest/src/public-form-routes.test.ts#__proto__ (#3022 anchor-enforcement: whitelist strip, __proto__ guard, publicFormGrant, LOOKUP_NOT_PUBLIC)", + "packages/rest/src/rest-route-ledger.ts (forms family: GET /forms/:slug, POST /forms/:slug/submit)", + "packages/rest/src/public-form-routes.test.ts#__proto__ (#3022 anchor-enforcement: whitelist strip, __proto__ guard, publicFormGrant; #21180 unconditional lookup/master_detail/user strip and the retired picker route)", "ADR-0056 Option A, #3022/#3036/#3004" ], "history": [ @@ -1484,6 +1484,12 @@ "date": "2026-08-08", "change": "new — merges the console/routes/ui/docs anonymous public-form rows into one item: whitelist-only render, forged-anchor strip on submit, publicFormGrant landing, non-whitelisted lookup refusal, unpublish-kills-link", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-10-01", + "change": "the lookup-refusal clause becomes the route's absence: ruling E retired the anonymous public-form lookup picker, so lookup / master_detail / user fields are always stripped from the anonymous rendering and GET /forms/:slug/lookup/:field is no longer registered (#21180)", + "ref": "claude/issue-21180-retire-public-picker" } ] }, diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 2eb709a7cd5..f3cf9555f5a 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -560,103 +560,8 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members })).toEqual([]); }); - // [#16106 review finding B1] A form field's `publicPicker.filter` is a static - // pre-filter the public-lookup route runs on the REFERENCED object - // (`picker.object`, else the field's `reference`). Binding it to the view's - // own object produced a FALSE refusal — the one failure direction this arm - // may never have — whenever the parent and the referenced object share a - // field name with differing types. - const pickerObjects = [ - { - name: 'crm_opportunity', - fields: { - close_date: { type: 'date' }, - account: { type: 'lookup', reference: 'crm_account' }, - contact: { type: 'lookup', reference: 'crm_contact' }, - owner_note: { type: 'text' }, - }, - }, - // Same field NAME as the parent, a select column whose option value collides with a preset. - { name: 'crm_account', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } }, - // Same field name, genuinely a date. - { name: 'crm_contact', fields: { close_date: { type: 'date' } } }, - ]; - const pickerForm = (fields: unknown[]) => ({ - objects: pickerObjects, - views: [{ - name: 'lead_form', type: 'form', - data: { provider: 'object', object: 'crm_opportunity' }, - sections: [{ fields }], - }], - }); - const pickerRule = { field: 'close_date', operator: 'equals', value: 'this_quarter' }; - - it('[B1] stays QUIET on a publicPicker filter over a referenced select column that shares its name with a parent date column', () => { - // The measured false refusal: parent `close_date` is a date, the picker queries `crm_account`. - expect(validatePresetComparands(pickerForm([ - { field: 'account', publicPicker: { filter: [pickerRule] } }, - ]))).toEqual([]); - // The `object` override names the referenced object outright. - expect(validatePresetComparands(pickerForm([ - { field: 'account', publicPicker: { object: 'crm_account', filter: [pickerRule] } }, - ]))).toEqual([]); - // Unresolvable pickers stay UNJUDGED, never the parent: a field the form - // object does not declare, and a field that is not a relationship (no - // `reference` to follow — on the parent it would have read as a date). - expect(validatePresetComparands(pickerForm([ - { field: 'no_such_field', publicPicker: { filter: [pickerRule] } }, - { field: 'close_date', publicPicker: { filter: [pickerRule] } }, - { field: 'owner_note', publicPicker: { filter: [pickerRule] } }, - ]))).toEqual([]); - }); - - it('[B1] POSITIVE CONTROL: the same picker filter is still refused when the REFERENCED object declares the field as a date', () => { - // Resolved through the field's `reference`. - expect(validatePresetComparands(pickerForm([ - { field: 'contact', publicPicker: { filter: [pickerRule] } }, - ])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']); - // Resolved through the `object` override (pointing a select-typed parent lookup at the date object). - expect(validatePresetComparands(pickerForm([ - { field: 'account', publicPicker: { object: 'crm_contact', filter: [pickerRule] } }, - ])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']); - }); - - // [#16403] The picker reader is entered by POSITION, not by key NAME. - // `scanForFilters` recognises a filter by key at ANY depth on all eight - // surfaces, so a reader keyed on the NAME `publicPicker` alone would be - // handed every future node that happens to spell it — and its unresolvable - // exit is `undefined`, which takes the whole filter subtree out of arm 2 - // SILENTLY. That is not a violation of "under-report only"; it is that - // budget being spent where no invariant test can see it. - const outsidePosition = (picker: Record) => ({ - objects: [ - { name: 'crm_opportunity', fields: { close_date: { type: 'date' } } }, - { name: 'crm_contact', fields: { close_date: { type: 'date' } } }, - ], - dashboards: [{ - name: 'sales', - // A widget is NOT a form field: it declares no `field`, so nothing here - // is a `FormFieldPublicPickerSchema` block whatever the key is called. - widgets: [{ id: 'w', object: 'crm_opportunity', publicPicker: picker }], - }], - }); - - it('[#16403] a `publicPicker` key outside the declared form-field position is bound by the ordinary readers, not by the picker reader', () => { - // Before the position guard this returned [] — the picker reader claimed - // the node on its key, found no enclosing `field`, and left through the - // `undefined` exit. - expect(validatePresetComparands(outsidePosition({ filter: { close_date: 'last_30_days' } })) - .map((f) => f.path)).toEqual(['dashboards[0].widgets[0].publicPicker.filter.close_date']); - // An `object` on the node still names the bound object — the picker - // reader's override and the ordinary `r.object` reader agree, so this half - // is unchanged by the guard. - expect(validatePresetComparands(outsidePosition({ object: 'crm_contact', filter: { close_date: 'last_30_days' } })) - .map((f) => f.path)).toEqual(['dashboards[0].widgets[0].publicPicker.filter.close_date']); - // And the arm still says nothing where the bound object makes it silent — - // the guard restores ordinary binding, it does not force a finding. - expect(validatePresetComparands(outsidePosition({ object: 'no_such_object', filter: { close_date: 'last_30_days' } }))) - .toEqual([]); - }); + // [#21180] The form field's anonymous public-lookup picker cases (#16106 B1, + // #16403) left with the retired key and the claiming reader that read it. it('keeps arm 1 field-agnostic: an ordering preset still fires with NO objects in the stack, and on a text column', () => { const findings = validatePresetComparands({ diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index 614554f5776..c4a57bd83ca 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -121,20 +121,11 @@ import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from * page's `object` when absent — the binding `validate-page-field-bindings` * already makes there (#19791); * - a lookup field's `lookupFilters` → that field's `reference`, else NOTHING: - * the picker queries the REFERENCED object, so like the public-lookup picker - * below it must never fall through to the object that owns the field (a - * `relatedListFilter` on the same field keeps binding to the owner, whose - * rows it filters) (#19791); - * - `publicPicker.object`, else the enclosing form field's `reference` - * resolved on the view's object, else NOTHING — a form field's public-lookup - * picker (`FormFieldPublicPickerSchema`) queries the REFERENCED object, so - * its `filter` must never fall through to the parent form object (#16106 - * review finding B1: that fall-through was a false refusal wherever the two - * objects share a field name with differing types). That reader claims the - * position by POSITION, not by key name: only where the enclosing record is - * a form field (`field`, required on `FormFieldBaseSchema`). A node that - * merely spells the same key somewhere else is bound by the ordinary - * readers below instead of inheriting this one's unjudged exit (#16403); + * the picker queries the REFERENCED object, so it must never fall through to + * the object that owns the field (a `relatedListFilter` on the same field + * keeps binding to the owner, whose rows it filters) (#19791). [#21180] The + * form field's anonymous public-lookup picker, the other claiming reader of + * this shape, left with the retired key it read; * - and, under `objects`, the object itself — its list views, tabs and * `relatedListFilter` (the filter runs over the CHILD rows, i.e. the object * that owns the field). @@ -389,15 +380,6 @@ function ancestorsOf( return { collection, chain, filterKey: rest[rest.length - 1] }; } -/** - * The key under which a form field carries its public-lookup picker - * (`FormFieldPublicPickerSchema`, `ui/view.zod.ts`). Its `filter` is a static - * pre-filter the public-lookup route runs on the REFERENCED object — - * `picker.object` when written, else the field definition's `reference` — - * never on the form's own object. - */ -const PUBLIC_PICKER_KEY = 'publicPicker'; - /** * [#19791] The filter key of a lookup field's picker filter * (`FieldSchema.lookupFilters`). The console lowers each `{ field, operator, @@ -426,18 +408,16 @@ function boundObjectOf( stack: AnyRec, path: string, datasets: ReadonlyMap, - graph: ObjectGraph, ): string | undefined { const located = ancestorsOf(stack, path); if (!located) return undefined; - // [#19791] A lookup field's picker filter is a CLAIMING reader, for the - // #16106 B1 reason the public-lookup picker below is one: its conditions - // address the REFERENCED object, so the position binds to the enclosing - // field's literal `reference` and to NOTHING otherwise. Falling through to - // the owning object would be a false refusal wherever the two objects share - // a field name with differing types. Unbound leaves arm 2 silent on this - // subtree only; arm 1 still judges it. + // [#19791] A lookup field's picker filter is a CLAIMING reader (the #16106 B1 + // reason): its conditions address the REFERENCED object, so the position + // binds to the enclosing field's literal `reference` and to NOTHING + // otherwise. Falling through to the owning object would be a false refusal + // wherever the two objects share a field name with differing types. Unbound + // leaves arm 2 silent on this subtree only; arm 1 still judges it. if (located.filterKey === LOOKUP_FILTERS_KEY) { const field = located.chain[located.chain.length - 1].node; return Object.prototype.hasOwnProperty.call(field, LOOKUP_FILTERS_KEY) @@ -445,7 +425,7 @@ function boundObjectOf( : undefined; } - return bindAncestors(located.collection, located.chain, located.chain.length - 1, datasets, graph); + return bindAncestors(located.collection, located.chain, located.chain.length - 1, datasets); } /** The reader loop behind {@link boundObjectOf}, from ancestor `from` outward. */ @@ -454,46 +434,10 @@ function bindAncestors( chain: readonly Ancestor[], from: number, datasets: ReadonlyMap, - graph: ObjectGraph, ): string | undefined { for (let i = from; i >= 0; i--) { const { key, node: r } = chain[i]; - // [#16106 B1] A form field's `publicPicker` is a CLAIMING reader: the - // picker queries the referenced object, so the position binds to - // `picker.object`, else to the `reference` of the enclosing form field - // resolved on the view's own object — and to NOTHING otherwise. Falling - // through to the view's `data.object` (the parent form object) bound the - // filter to the wrong object and produced a FALSE refusal wherever the - // parent and the referenced object share a field name with differing - // types (a `date` on the parent, a `select` whose option value is a - // preset name on the referenced object). - // - // [#16403] The branch is entered by POSITION, never by key NAME alone. - // `publicPicker` is declared in exactly ONE place — `FormFieldBaseSchema` - // (`ui/view.zod.ts`), where the enclosing record is a form field and its - // `field` is REQUIRED — so the enclosing `field` identifies the position, - // and it is the same read the branch already has to make. Matching on the - // key alone would hand this reader every future node that happens to spell - // `publicPicker`, at any depth on any of the eight surfaces, because - // `scanForFilters` recognises a filter by key rather than by declared - // carrier; such a node would leave through this reader's `undefined` exit - // and take its whole filter subtree out of arm 2 SILENTLY. Under-reporting - // is the only failure direction this arm may have, so that hole could - // never VIOLATE the invariant — it would quietly spend it, where no test - // asking "was the invariant violated?" can see it. Outside the declared - // position the node falls through to the ordinary nearest-ancestor readers - // below, exactly like every other key this walk does not recognise. - const formField = key === PUBLIC_PICKER_KEY ? strName(chain[i - 1]?.node.field) : undefined; - if (formField) { - const override = literalObjectName(r.object); - if (override) return override; - const formObject = bindAncestors(collection, chain, i - 2, datasets, graph); - if (!formObject) return undefined; - const verdict = resolveFieldPath(graph, formObject, formField); - return verdict?.kind === 'ok' ? strName(verdict.meta?.reference) : undefined; - } - // [#19791] A list page's `interfaceConfig` names its object `source`. Read // at that one position — the page's own config, directly under the // `pages` item — and NOT claiming: without a `source` the search goes on @@ -776,7 +720,7 @@ export function validatePresetComparands( } walkAuthoredFilters(stack, PRESET_COMPARAND_SURFACES, ({ value, path, where }) => { - const isTemporal = temporalFieldOracle(graph, boundObjectOf(stack, path, datasets, graph)); + const isTemporal = temporalFieldOracle(graph, boundObjectOf(stack, path, datasets)); judgeFilterValue(value, path, where, out, 0, isTemporal); }); diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index 1fc0f3f0095..6ee6350c48f 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -223,7 +223,12 @@ describe('#5364 saveMetaItem 422 expands union branches', () => { * fix that did not reach this envelope would be a fix nobody sees. */ describe('#7510 the 422 for a broken ViewItem names its own key, not the container\'s', () => { - /** The card's repro: a form ViewItem whose field's `publicPicker` carries `sort`. */ + /** + * The card's repro: a form ViewItem whose field carries an unknown subkey in + * a nested block. It was measured on `publicPicker.sort`; [#21180] retired + * that key (ruling E on #21079), so the repro rides on `keyField`, the other + * strict block a form field carries — same shape, same door. + */ const pickerReproView = () => ({ name: 'lead.contact', object: 'lead', @@ -234,7 +239,7 @@ describe('#7510 the 422 for a broken ViewItem names its own key, not the contain data: { provider: 'object', object: 'lead' }, sections: [{ label: 'About you', - fields: [{ field: 'owner', publicPicker: { displayFields: ['name'], sort: [{ field: 'email', order: 'desc' }] } }], + fields: [{ field: 'owner', keyField: { field: 'name', sort: [{ field: 'email', order: 'desc' }] } }], }], }, }); @@ -257,7 +262,7 @@ describe('#7510 the 422 for a broken ViewItem names its own key, not the contain const unknownKey = err.issues.find((i: any) => i.code === 'unrecognized_keys'); expect(unknownKey).toBeDefined(); expect(unknownKey.message).toContain('`sort`'); - expect(unknownKey.path).toBe('config.sections.0.fields.0.publicPicker'); + expect(unknownKey.path).toBe('config.sections.0.fields.0.keyField'); // ⛔ The measured misdirect, gone from the whole envelope: on // `origin/main` @ `9051802` this message was the container branch's. @@ -268,7 +273,7 @@ describe('#7510 the 422 for a broken ViewItem names its own key, not the contain it('the same item minus the bad subkey still saves', async () => { const { protocol, rows } = makeProtocol(); const item: any = pickerReproView(); - delete item.config.sections[0].fields[0].publicPicker.sort; + delete item.config.sections[0].fields[0].keyField.sort; const result = await save(protocol, item, 'lead.contact'); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8934b6128ed..e39ca96ff95 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1294,10 +1294,11 @@ export function graftNormalizedOperators(authored: unknown, parsed: unknown): un * `saveMeta` persists the authored body verbatim (deliberately: `parsed.data` * strips the Studio-only auxiliary fields that ride along with an overlay). A * Studio-saved public form therefore reached every `sections`-reading consumer - * still spelled `groups`, and `packages/rest`'s three `/forms/:slug` routes - * degrade on exactly that: an empty published field schema, an empty - * `allowedFields` whitelist on submit (#6920), and `403 LOOKUP_NOT_PUBLIC` for - * every field. Same shape of gap as {@link graftNormalizedOperators}, and the + * still spelled `groups`, and `packages/rest`'s `/forms/:slug` routes degraded + * on exactly that: an empty published field schema, an empty `allowedFields` + * whitelist on submit (#6920), and a 403 for every field on the anonymous + * lookup picker (a route since retired, #21180). Same shape of gap as + * {@link graftNormalizedOperators}, and the * same consequence — while saves keep minting the authored spelling, the alias * can never be retired and the objectui-side folds cannot be removed. * diff --git a/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts b/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts deleted file mode 100644 index 99e296c85e6..00000000000 --- a/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// [#21062] A public lookup picker whose FIRST display field declares a masking -// rule serves its rows, on a real boot, to a caller the rule applies to. -// -// The picker searches and sorts by one key. It is the first display field the -// caller may query on, by the security service's published answer -// (`getQueryableFields`), not blindly the first display field. A field whose -// masking rule applies to a caller is served to it masked and may not be -// searched or sorted on, so a picker keyed on it answered `403` to every such -// caller. -// -// What is asserted, by class, for a visitor with no session on a deployment -// that registers no profile for public forms: the scene is real (a system read -// carries the stored values); the rows are sorted on the next queryable display -// field, and the search matches it; the masked field is still served, masked; -// a signed-in caller reaching the same door is served the same (the door builds -// its own context); and a picker whose only display field is masked answers -// the engine's refusal, `403 PERMISSION_DENIED`. -// -// `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and -// auth layers. `@objectstack/rest` resolves to its BUILT output here (no source -// alias), so build it before reading a verdict. Fixtures are synthetic. - -import { describe, it, expect } from 'vitest'; -import { bootStack } from '@objectstack/verify'; -import { defineStack, defineView } from '@objectstack/spec'; -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -const CONTACT = 'pqkey_contact'; -const INQUIRY = 'pqkey_inquiry'; -const KEY = 'pqkey_code'; -const SYS = { context: { isSystem: true } } as const; - -/** Synthetic rows whose order by name and by the masked field differ. */ -const SEED = [ - { name: 'Bravo Synthetic', [KEY]: 'SYNTH1AAA' }, - { name: 'Alpha Synthetic', [KEY]: 'SYNTH3CCC' }, - { name: 'Charlie Synthetic', [KEY]: 'SYNTH2BBB' }, -]; -const BY_NAME = ['Alpha Synthetic', 'Bravo Synthetic', 'Charlie Synthetic']; - -const PqkeyContact = ObjectSchema.create({ - name: CONTACT, - label: 'Picker Key Contact', - pluralLabel: 'Picker Key Contacts', - sharingModel: 'public_read_write', - fields: { - name: Field.text({ label: 'Name', required: true }), - [KEY]: Field.text({ label: 'Code', maskingRule: { keepHead: 1, keepTail: 1 } }), - }, -}); - -const PqkeyInquiry = ObjectSchema.create({ - name: INQUIRY, - label: 'Picker Key Inquiry', - pluralLabel: 'Picker Key Inquiries', - sharingModel: 'public_read_write', - fields: { - subject: Field.text({ label: 'Subject', required: true }), - contact: Field.lookup(CONTACT, { label: 'Contact' }), - contact_masked_only: Field.lookup(CONTACT, { label: 'Contact (masked only)' }), - }, -}); - -const data = { provider: 'object' as const, object: INQUIRY }; -const PqkeyInquiryViews = defineView({ - list: { label: 'Inquiries', type: 'grid', data, columns: [{ field: 'subject' }] }, - formViews: { - intake: { - type: 'simple', - data, - sections: [ - { - name: 'intake', - label: 'Intake', - columns: 1, - fields: [ - { field: 'subject', required: true }, - { field: 'contact', publicPicker: { displayFields: [KEY, 'name'] } }, - { field: 'contact_masked_only', publicPicker: { displayFields: [KEY] } }, - ], - }, - ], - sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/pqkey-intake' }, - }, - }, -}); - -const pqkeyStack = defineStack({ - manifest: { - id: 'com.dogfood.picker-queryable-key', - namespace: 'pqkey', - version: '0.0.0', - type: 'app', - name: 'Picker Queryable Key Fixture', - description: 'One object with a masked field, and a public form whose picker displays it first.', - }, - objects: [PqkeyContact, PqkeyInquiry], - views: [PqkeyInquiryViews], -}); - -type Stack = Parameters[0]; -type Row = Record; - -function expectServedMasked(rows: Row[]): void { - const stored = new Map(SEED.map((r) => [r.name, r[KEY]])); - for (const row of rows) { - const value = row[KEY]; - expect(typeof value, 'the masked field is served, as a string').toBe('string'); - expect(value).not.toBe(stored.get(String(row.name))); - expect(String(value)).toContain('*'); - } -} - -describe('[#21062] a public picker whose first display field is masked serves rows sorted on the next queryable one', () => { - it( - 'to a visitor with no session and to a signed-in caller; a picker with no queryable display field is refused', - async () => { - const stack = await bootStack(pqkeyStack as unknown as Stack); - try { - const ql = (await stack.kernel.getServiceAsync('objectql')) as any; - for (const row of SEED) await ql.insert(CONTACT, { ...row }, SYS); - const stored = await ql.find(CONTACT, { where: {}, context: { isSystem: true } }); - expect(stored.map((r: Row) => r[KEY]).sort(), 'the stored values are what a system read serves') - .toEqual(SEED.map((r) => r[KEY]).sort()); - - const token = await stack.signUp('pqkey-member@verify.test'); - for (const [who, call] of [ - ['a visitor with no session', (path: string) => stack.api(path)], - ['a signed-in caller', (path: string) => stack.apiAs(token, 'GET', path)], - ] as const) { - const listed = await call('/forms/pqkey-intake/lookup/contact'); - expect(listed.status, who).toBe(200); - const rows = ((await listed.json()) as { data: Row[] }).data; - expect(rows.map((r) => r.name), `${who}: sorted on the next queryable display field`).toEqual(BY_NAME); - expectServedMasked(rows); - - const searched = await call('/forms/pqkey-intake/lookup/contact?q=Charlie'); - expect(searched.status, who).toBe(200); - const hits = ((await searched.json()) as { data: Row[] }).data; - expect(hits.map((r) => r.name), `${who}: the search matches the next queryable display field`) - .toEqual(['Charlie Synthetic']); - expectServedMasked(hits); - } - - const refused = await stack.api('/forms/pqkey-intake/lookup/contact_masked_only'); - expect(refused.status).toBe(403); - const refusal = (await refused.json()) as { code?: unknown; error?: { code?: unknown } }; - expect(refusal.code ?? refusal.error?.code).toBe('PERMISSION_DENIED'); - } finally { - await stack.stop(); - } - }, - 120_000, - ); -}); diff --git a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts index 52f02bf4620..15008ac18ac 100644 --- a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts +++ b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts @@ -5,19 +5,20 @@ // // `maskingRule` declares itself for "every non-system caller unless the // field's `requiredPermissions` are ALL held". A caller who resolves no -// permission set holds nothing, so the rule applies to it. Two doors that -// produce that caller on a real composition are driven here: +// permission set holds nothing, so the rule applies to it. The door that +// produces that caller on a real composition is driven here: the record door, +// for a signed-in user on a deployment whose baseline is switched off +// (`fallbackPermissionSet: null`) and who holds no grant. // -// - the public form's lookup picker, which serves the referenced object's -// declared display fields to a visitor with no session, on a deployment -// that registers no profile for public forms; and -// - the record door, for a signed-in user on a deployment whose baseline is -// switched off (`fallbackPermissionSet: null`) and who holds no grant. +// [#21180] There used to be a second door — the public form's anonymous lookup +// picker, serving the referenced object's display fields to a visitor with no +// session. Ruling E on #21079 (comment 5933054144) retired the picker and +// deleted its route, so that case and the public form it booted left with it. // // What is asserted, by class: the scene is real (a system read carries the // stored value); the field is served masked, never stored; a field with no -// rule beside it is served as stored (the door really served the row); and, -// on the record door, a predicate on the masked field is refused. +// rule beside it is served as stored (the door really served the row); and a +// predicate on the masked field is refused. // // `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and // auth layers. `@objectstack/plugin-security` resolves to its BUILT output @@ -26,12 +27,11 @@ import { describe, it, expect } from 'vitest'; import { bootStack } from '@objectstack/verify'; -import { defineStack, defineView } from '@objectstack/spec'; +import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { SecurityPlugin } from '@objectstack/plugin-security'; const CONTACT = 'zsmask_contact'; -const INQUIRY = 'zsmask_inquiry'; const KEY = 'zsmask_code'; /** Synthetic stored values. */ const STORED = 'SYNTH5150VALUE'; @@ -49,40 +49,6 @@ const ZsmaskContact = ObjectSchema.create({ }, }); -const ZsmaskInquiry = ObjectSchema.create({ - name: INQUIRY, - label: 'Zero-set Mask Inquiry', - pluralLabel: 'Zero-set Mask Inquiries', - sharingModel: 'public_read_write', - fields: { - subject: Field.text({ label: 'Subject', required: true }), - contact: Field.lookup(CONTACT, { label: 'Contact' }), - }, -}); - -const data = { provider: 'object' as const, object: INQUIRY }; -const ZsmaskInquiryViews = defineView({ - list: { label: 'Inquiries', type: 'grid', data, columns: [{ field: 'subject' }] }, - formViews: { - intake: { - type: 'simple', - data, - sections: [ - { - name: 'intake', - label: 'Intake', - columns: 1, - fields: [ - { field: 'subject', required: true }, - { field: 'contact', publicPicker: { displayFields: ['name', KEY] } }, - ], - }, - ], - sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/zsmask-intake' }, - }, - }, -}); - const zsmaskStack = defineStack({ manifest: { id: 'com.dogfood.zero-set-mask', @@ -90,10 +56,9 @@ const zsmaskStack = defineStack({ version: '0.0.0', type: 'app', name: 'Zero-set Mask Fixture', - description: 'One object with one masked field, and a public form whose picker displays it.', + description: 'One object with one masked field.', }, - objects: [ZsmaskContact, ZsmaskInquiry], - views: [ZsmaskInquiryViews], + objects: [ZsmaskContact], }); type Stack = Parameters[0]; @@ -116,25 +81,6 @@ function expectMasked(value: unknown): void { } describe('[#20995] a masked field is served masked to a caller who resolves no permission set', () => { - it( - 'on the public form lookup door, to a visitor with no session', - async () => { - const stack = await bootStack(zsmaskStack as unknown as Stack); - try { - await seedContact(stack); - const res = await stack.api('/forms/zsmask-intake/lookup/contact'); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array> }; - expect(body.data).toHaveLength(1); - expect(body.data[0].name, 'the door served the row').toBe(NAME); - expectMasked(body.data[0][KEY]); - } finally { - await stack.stop(); - } - }, - 120_000, - ); - it( 'on the record door, to a signed-in user holding no grant on a deployment with no baseline', async () => { diff --git a/packages/rest/src/public-form-lookup-filter-lowering.test.ts b/packages/rest/src/public-form-lookup-filter-lowering.test.ts deleted file mode 100644 index c837287333e..00000000000 --- a/packages/rest/src/public-form-lookup-filter-lowering.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16581] `GET /forms/:slug/lookup/:field` answers a SEARCH, not a 400. - * - * ## The defect - * - * The route composed its filter list out of `ViewFilterRule` objects — the - * `{ field, operator, value }` dialect `FormFieldPublicPickerSchema.filter` - * declares in so many words ("Same `{ field, operator, value }` dialect as - * list-view filters") — and put them on the `findData` filter slot, which - * accepts a `FilterCondition` object or a `FilterArray` and refuses everything - * else with `400 INVALID_FILTER`. ⭐ The `q` branch builds the SAME object shape - * itself, so the refusal did not depend on an author declaring - * `publicPicker.filter`: every non-empty search 400'd, and only the degenerate - * empty-filter call could succeed. That is the endpoint's entire purpose, on an - * anonymous surface an applicant has no way around. - * - * ## Why this file exists next to `public-form-lookup-picker.test.ts` - * - * That suite stubs `findData` and pins the route's request COMPOSITION, so it - * could never have met the ingress's verdict on the value it composed — which - * is exactly how a route shipped for this long building a filter nothing would - * parse. Here the protocol's `findData` is the REAL - * `ObjectStackProtocolImplementation`, so the request crosses the same - * normalizer a served deployment uses and the assertions are on the ANSWER - * (status and rows), not on the source this card wrote. - * - * ## ⭐ §3 is the discriminating control and is not optional - * - * "The route lowers correctly" and "the parser was loosened" produce the same - * green in §1 and §2 and have opposite consequences. §3 keeps the card's own - * control pair — the object shape and the triple shape, fed to the ingress - * DIRECTLY through `GET /data/:object`'s `$filter` — and asserts the object - * shape is still refused. ⛔ Never delete or "repair" §3 to make a change pass: - * a green §1/§2 means nothing without it. (`rest-server-canonical-query-ast.ts`'s - * §3 CONTROL pins the same fact from its own frozen literal; two independent - * pins, deliberately.) - */ - -import { describe, expect, it, vi } from 'vitest'; -// The engine-double contract (#4434 / #5619): a fake engine's update/delete -// must be exactly as strict as ObjectQL's dispatch, or a dead route ships with -// its suite green. Both predicates live in metadata-core. -import { - assertEngineDeleteDispatch, - assertEngineUpdateDispatch, - assertEngineFindOnePredicate, - type EngineFindOneQueryInput, -} from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { RestServer } from './rest-server.js'; - -// ─── the fixture: the card's own shape (an anonymous job-application form) ─── - -const JOBS = [ - { id: 'job_1', title: 'Senior software engineer', city: 'Berlin', status: 'published' }, - { id: 'job_2', title: 'Lead engineer', city: 'Lisbon', status: 'draft' }, - { id: 'job_3', title: 'Product designer', city: 'Berlin', status: 'published' }, - { id: 'job_4', title: 'Staff engineer', city: 'Remote', status: 'published' }, -]; - -const jobObject = { - name: 'ats_job', - label: 'Job', - nameField: 'title', - fields: { - id: { name: 'id', type: 'text' }, - title: { name: 'title', type: 'text', label: 'Title' }, - city: { name: 'city', type: 'text', label: 'City' }, - status: { name: 'status', type: 'text', label: 'Status' }, - }, -}; - -const applicationObject = { - name: 'ats_application', - label: 'Application', - fields: { - id: { name: 'id', type: 'text' }, - job: { name: 'job', type: 'lookup', reference: 'ats_job', label: 'Job' }, - }, -}; - -/** The picker the card declares, verbatim. */ -const PICKER_WITH_FILTER = { - displayFields: ['title', 'city'], - filter: [{ field: 'status', operator: 'equals', value: 'published' }], -}; - -/** The same picker with NO declared filter — the case that 400'd anyway. */ -const PICKER_NO_FILTER = { displayFields: ['title', 'city'] }; - -const applyForm = (picker: unknown) => ({ - name: 'ats_application.apply', - object: 'ats_application', - viewKind: 'form', - label: 'Apply', - config: { - type: 'simple', - data: { provider: 'object', object: 'ats_application' }, - sharing: { allowAnonymous: true, publicLink: '/forms/apply' }, - sections: [{ label: 'Your application', fields: [{ field: 'job', publicPicker: picker }] }], - }, -}); - -// ─── the real save path, so the fixture is a form the spec ACCEPTS ────────── - -/** The slice of the engine the `sys_metadata` write path touches. */ -function metadataEngine() { - const rows: Array> = []; - let nextId = 0; - return { - rows, - engine: { - async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); return null; - }, - async find() { return rows.slice(); }, - async insert(table: string, data: Record) { - if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; - nextId += 1; - rows.push({ id: `r_${nextId}`, ...data }); - return { id: `r_${nextId}` }; - }, - async update(_t: string, data: Record, opts: { where: Record }) { - assertEngineUpdateDispatch(data, opts); - return { id: null }; - }, - async delete(_t: string, opts: { where: Record }) { - assertEngineDeleteDispatch(opts); - return { deleted: 0 }; - }, - registry: { registerItem: () => {}, registerObject: () => {}, listItems: () => [] }, - } as any, - }; -} - -/** - * Persist a view through the REAL `saveMetaItem` and return the stored body. - * A 422 here would mean the picker fixture is not spec-valid, which would make - * every route assertion below a statement about an unauthorable form. - */ -async function persistedBody(item: unknown): Promise { - const { engine, rows } = metadataEngine(); - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - const result = await protocol.saveMetaItem({ type: 'view', name: 'ats_application.apply', item }); - expect(result.success, JSON.stringify(result)).toBe(true); - const row = rows.find((r) => r.type === 'view'); - expect(row, 'the save persisted no view row').toBeDefined(); - return JSON.parse(row!.metadata); -} - -// ─── the data engine: rows filtered by the condition that REALLY arrives ──── - -/** - * Evaluate a lowered `FilterCondition` against a row. - * - * ⚠️ Deliberately tiny and deliberately LOUD. It implements exactly the three - * comparisons this card's filters lower to and throws on anything else, - * including an array — a filter still in the authoring dialect reaching a - * driver is the defect itself, and a matcher that shrugged at it would let a - * half-lowered filter pass as "the right rows". Case-sensitive `$contains` - * follows the spec's split (`icontains` is the insensitive twin); which of the - * two the route composes is #16581's business, not this matcher's. - */ -function matchesCondition(row: Record, cond: unknown): boolean { - if (cond === undefined || cond === null) return true; - if (Array.isArray(cond)) { - if (cond.length === 0) return true; // `[]` — "no filter", every path reads it so - throw new Error(`an UNLOWERED filter reached the driver: ${JSON.stringify(cond)}`); - } - if (typeof cond !== 'object') throw new Error(`unexpected filter: ${JSON.stringify(cond)}`); - for (const [key, expected] of Object.entries(cond as Record)) { - if (key === '$and') { - if (!(expected as unknown[]).every((c) => matchesCondition(row, c))) return false; - continue; - } - if (key === '$or') { - if (!(expected as unknown[]).some((c) => matchesCondition(row, c))) return false; - continue; - } - if (expected && typeof expected === 'object' && !Array.isArray(expected)) { - for (const [op, operand] of Object.entries(expected as Record)) { - if (op === '$eq') { - if (row[key] !== operand) return false; - } else if (op === '$ne') { - if (row[key] === operand) return false; - } else if (op === '$contains') { - if (!String(row[key] ?? '').includes(String(operand))) return false; - } else { - throw new Error(`this suite's matcher does not implement "${op}"`); - } - } - continue; - } - if (row[key] !== expected) return false; // implicit-equality form - } - return true; -} - -/** The option bag `engine.find` last received, for the receipt assertions. */ -type DataEngine = { engine: any; seen: () => Record | undefined }; - -function dataEngine(): DataEngine { - let seen: Record | undefined; - const objects: Record = { ats_job: jobObject, ats_application: applicationObject }; - const engine = { - registry: { getObject: (n: string) => objects[n] }, - find: async (object: string, options: Record) => { - seen = options; - if (object !== 'ats_job') return []; - const rows = JOBS.filter((r) => matchesCondition(r, options?.where)); - // Hold the caller's bound, AFTER the filter and by PRESENCE — a - // limit-blind double reports a page size the engine never granted - // (`check:objectql-double-limit`). The picker sends - // `limit: maxResults`, so this is also the shape it really meets. - return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; - }, - aggregate: async () => [], - count: async () => 0, - }; - return { engine, seen: () => seen }; -} - -// ─── the real routes over a REAL `findData` ───────────────────────────────── - -function mockServer() { - return { - get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), - use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - }; -} - -function mockRes() { - const res: any = { statusCode: 200, body: undefined }; - res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); - res.json = vi.fn((b: any) => { res.body = b; return res; }); - res.header = vi.fn(() => res); - res.end = vi.fn(() => res); - return res; -} - -/** - * Mount the real routes. `getMetaItems` is stubbed (it serves the stored form - * and the object definitions); `findData` is the REAL protocol's, bound to the - * data engine above — so the filter this route composes crosses the real - * ingress and the real lowering before any row is matched. - */ -function routesOver(storedView: any) { - const { engine, seen } = dataEngine(); - const real = new ObjectStackProtocolImplementation(engine as never) as any; - const protocol: any = { - getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), - getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItem: vi.fn().mockResolvedValue(undefined), - getMetaItems: vi.fn(async ({ type }: { type: string }) => { - if (type === 'view') return [storedView]; - if (type === 'object') return [jobObject, applicationObject]; - return []; - }), - findData: (request: unknown) => real.findData(request), - }; - const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); - rest.registerRoutes(); - const route = (method: string, suffix: string) => { - const found = rest.getRoutes().find((r: any) => r.method === method && r.path.endsWith(suffix)); - if (!found) throw new Error(`route ${method} …${suffix} is not mounted`); - return found as any; - }; - return { seen, lookup: route('GET', '/forms/:slug/lookup/:field'), list: route('GET', '/data/:object') }; -} - -/** Drive the anonymous picker exactly as a browser does: no cookie, one `q`. */ -async function lookup(storedView: any, q?: string) { - const { lookup: route, seen } = routesOver(storedView); - const res = mockRes(); - await route.handler({ params: { slug: 'apply', field: 'job' }, query: q === undefined ? {} : { q } } as any, res); - return { status: res.statusCode, body: res.body, where: seen()?.where }; -} - -// --------------------------------------------------------------------------- -// §1 the `q` branch — the half that 400'd with NO declared filter at all -// --------------------------------------------------------------------------- - -describe('[#16581] §1 the route lowers the search predicate it builds itself', () => { - it('q=engineer answers 200 with the matching rows, not 400 INVALID_FILTER', async () => { - // The card's headline call. Before the fix this was - // `400 {"code":"INVALID_FILTER"}` — the route's own `{ field, operator: - // 'contains', value: q }` row is the object dialect too, so no author - // had to declare anything for the endpoint to be unusable. - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - const { status, body, where } = await lookup(stored, 'engineer'); - - expect(status).toBe(200); - expect(body.data).toEqual([ - { id: 'job_1', title: 'Senior software engineer', city: 'Berlin' }, - { id: 'job_2', title: 'Lead engineer', city: 'Lisbon' }, - { id: 'job_4', title: 'Staff engineer', city: 'Remote' }, - ]); - // The receipt: what the ENGINE received is a lowered `FilterCondition`, - // which is the only way the rows above could have been produced. - expect(where).toEqual({ title: { $contains: 'engineer' } }); - }); - - it('the degenerate empty search still answers 200 — the one call that always worked', async () => { - // A guard, not a new capability: `filters: []` was the single shape the - // ingress accepted before, and the lowering must not turn "no filter" - // into `['and']`, a logical node with nothing to join that the ingress - // refuses outright. - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - const { status, body, where } = await lookup(stored); - - expect(status).toBe(200); - expect(body.data.map((r: any) => r.id)).toEqual(['job_1', 'job_2', 'job_3', 'job_4']); - expect(where).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// §2 the declared `publicPicker.filter` branch, and the two composed -// --------------------------------------------------------------------------- - -describe('[#16581] §2 the declared filter is lowered too, and ANDed ahead of the search', () => { - it('the declared filter alone answers 200 and really restricts the rows', async () => { - const stored = await persistedBody(applyForm(PICKER_WITH_FILTER)); - const { status, body, where } = await lookup(stored); - - expect(status).toBe(200); - // `job_2` is `draft`: the declared pre-filter is APPLIED, not merely - // accepted. On this surface that distinction is the security property — - // the filter is what keeps an anonymous visitor inside the rows the form - // is allowed to expose. - expect(body.data.map((r: any) => r.id)).toEqual(['job_1', 'job_3', 'job_4']); - expect(where).toEqual({ status: 'published' }); - }); - - it('the declared filter AND the visitor search compose — the card\'s full combination', async () => { - const stored = await persistedBody(applyForm(PICKER_WITH_FILTER)); - const { status, body, where } = await lookup(stored, 'engineer'); - - expect(status).toBe(200); - // `job_3` fails the search, `job_2` fails the declared filter: only rows - // passing BOTH survive, which is what proves both branches lowered. - expect(body.data).toEqual([ - { id: 'job_1', title: 'Senior software engineer', city: 'Berlin' }, - { id: 'job_4', title: 'Staff engineer', city: 'Remote' }, - ]); - expect(where).toEqual({ $and: [{ status: 'published' }, { title: { $contains: 'engineer' } }] }); - }); - - it('a legacy operator spelling in a STORED row folds through the spec\'s own normalizer', async () => { - // `notEquals` is a `VIEW_FILTER_OPERATOR_ALIASES` row: authored today the - // schema folds it on parse, but a row stored before that fold — and this - // route reads STORED bodies, never re-parsed ones — still carries it. - // The lowering reuses `normalizeFilterOperator`, the schema's own - // preprocess, so the canonical spelling is what reaches the parser. ⛔ A - // second alias table here is what that reuse exists to prevent. - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - stored.config.sections[0].fields[0].publicPicker.filter = [ - { field: 'status', operator: 'notEquals', value: 'draft' }, - ]; - const { status, body, where } = await lookup(stored); - - expect(status).toBe(200); - expect(body.data.map((r: any) => r.id)).toEqual(['job_1', 'job_3', 'job_4']); - expect(where).toEqual({ status: { $ne: 'draft' } }); - }); - - it('an unreadable stored rule is FORWARDED, so the request is still refused — never served unfiltered', async () => { - // The fail-closed direction, stated as a test because the tempting - // repair is the opposite one. A row the lowering cannot read as a rule - // is passed through and the ingress refuses the whole request; dropping - // it would answer 200 over an UNFILTERED table on an anonymous surface — - // a widening delivered silently by the code repairing a refusal. - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - stored.config.sections[0].fields[0].publicPicker.filter = [{ nonsense: true }]; - const { status, body } = await lookup(stored, 'engineer'); - - expect(status).toBe(400); - expect(body.code).toBe('INVALID_FILTER'); - }); -}); - -// --------------------------------------------------------------------------- -// ⭐ §3 THE DISCRIMINATING CONTROL — the card's own control pair -// --------------------------------------------------------------------------- - -describe('[#16581] §3 CONTROL: the parser was NOT loosened — the object shape still answers 400', () => { - /** `GET /data/:object?$filter=…` on the same server, same ingress. */ - async function dataApi($filter: string) { - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - const { list } = routesOver(stored); - const res = mockRes(); - await list.handler({ params: { object: 'ats_job' }, query: { $filter } } as any, res); - return { status: res.statusCode, body: res.body }; - } - - it('the OBJECT shape fed straight to the parser is refused — 400 INVALID_FILTER', async () => { - // ⭐ Without this assertion a green §1/§2 cannot be told apart from "the - // parser was loosened to accept `ViewFilterRule` objects", which is the - // repair the ruling excludes: it would maintain two filter grammars in - // the data layer forever and spread the shape to every `findData` - // caller. ⛔ Do not delete, weaken or "repair" this expectation. - const { status, body } = await dataApi('[{"field":"status","operator":"equals","value":"published"}]'); - expect(status).toBe(400); - expect(body.code).toBe('INVALID_FILTER'); - expect(body.error).toContain('is not a recognised filter shape'); - }); - - it('…and the TRIPLE shape on the same call answers 200 — the pair attributes the failure to SHAPE', async () => { - // The other half of the card's control: same server, same object, same - // anonymity, only the filter's shape differs. That is what rules out - // permissions, anonymity and every other part of the route as the cause. - // - // `records` is the key `findData` returns — this route hands its result - // through untouched, which is also how the picker's own `data`/`items` - // read was measured to match nothing (repaired in the same card). - const { status, body } = await dataApi('[["status","=","published"]]'); - expect(status).toBe(200); - expect(body.records.map((r: any) => r.id)).toEqual(['job_1', 'job_3', 'job_4']); - }); -}); - -// --------------------------------------------------------------------------- -// §4 the response the route READS back — the second half of "the right rows" -// --------------------------------------------------------------------------- - -/** - * The picker read `result.data ?? result.items` and never `result.records`, - * which is the key `findData` returns (`{ object, records, total, hasMore }`) - * and the order the file's three other read sites already use. So with the - * filter lowered the route answered `200 {"data":[]}` — an empty picker for - * every search, the same user-visible outcome as the 400 by a different route. - * - * It was invisible twice over: unreachable while every non-empty search 400'd, - * and unreachable in `public-form-lookup-picker.test.ts`, whose `findData` - * double answers `{ data: rows }` — a shape the real protocol does not produce. - * A double that invents its subject's response shape cannot report that the - * consumer reads the wrong key. - */ -describe('[#16581] §4 the projection reads `records`, the key `findData` actually returns', () => { - it('rows survive the real response envelope — not 200 with an empty list', async () => { - const stored = await persistedBody(applyForm(PICKER_WITH_FILTER)); - const { status, body } = await lookup(stored, 'engineer'); - - expect(status).toBe(200); - expect(body.total).toBe(2); - expect(body.data.length).toBe(2); - }); - - it('the legacy `data` envelope a protocol double may answer with still works', async () => { - // The aliases are kept, so the sibling suite's double and any alternate - // protocol keep being read. Driven here rather than assumed. - const stored = await persistedBody(applyForm(PICKER_NO_FILTER)); - const rest = new RestServer(mockServer() as any, { - getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), - getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItem: vi.fn().mockResolvedValue(undefined), - getMetaItems: vi.fn(async ({ type }: { type: string }) => { - if (type === 'view') return [stored]; - if (type === 'object') return [jobObject, applicationObject]; - return []; - }), - findData: vi.fn().mockResolvedValue({ data: [{ id: 'job_9', title: 'Legacy envelope', city: 'Oslo' }] }), - } as any, { api: { requireAuth: false } } as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); - rest.registerRoutes(); - const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path.endsWith('/forms/:slug/lookup/:field'))!; - const res = mockRes(); - await (route as any).handler({ params: { slug: 'apply', field: 'job' }, query: {} } as any, res); - - expect(res.statusCode).toBe(200); - expect(res.body.data).toEqual([{ id: 'job_9', title: 'Legacy envelope', city: 'Oslo' }]); - }); -}); diff --git a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts deleted file mode 100644 index 88f2fbe4be1..00000000000 --- a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#21062] The public lookup picker searches and sorts by ONE key: the first of - * its display fields the caller may QUERY ON, by the security service's - * published answer (`getQueryableFields`, #20935) — not blindly the first - * display field. - * - * A field whose masking rule applies to a caller is SERVED to it, its value - * masked, so it is a display field the picker can still show. It is not a - * field the caller may query on: a `contains` search on it rebuilds the masked - * value probe by probe, and an order on it ranks rows by the value the mask - * hides. The engine refuses both, `403 PERMISSION_DENIED`. A picker keyed on - * its first display field therefore answered that 403 to every caller the - * first field's rule applies to, on every request. - * - * What is pinned, by caller class (the picker's own context — the route builds - * it, the session is not read): - * - * - masked classes — the deployment registers no `guest_portal` set (the - * context resolves no set), or registers one that does not hold the - * capability the rule names: rows are sorted, and searched, on the next - * queryable display field; the masked field is still served, masked; and no - * request the engine receives names the masked field; - * - a picker with no queryable display field answers the engine's own refusal - * for those fields, and the engine is never asked; - * - controls — a picker with no masked display field, and a caller the rule is - * lifted for, keep the first display field as the key; - * - a security service that cannot give the query answer: every display field - * whose declaration carries a masking rule is passed over, whoever the - * caller is — the fallback the service contract prescribes. - * - * The composition is real below the route: `SecurityPlugin` over a real - * `ObjectQL` on a real `SqlDriver` (SQLite), and the REAL protocol `findData`, - * so the request the route composes crosses the real ingress and the engine's - * own field guards. The form and object reads are stubbed. Fixtures are - * synthetic. - */ - -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { FormFieldPublicPickerSchema } from '@objectstack/spec/ui'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { SecurityPlugin } from '@objectstack/plugin-security'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { RestServer } from './rest-server.js'; - -const CONTACT = 'rest_pk_contact'; -const INQUIRY = 'rest_pk_inquiry'; -const MASKED = 'pk_code'; -const CAPABILITY = 'pk_unmask'; -const SLUG = 'pk-intake'; - -const SYS_CTX = { isSystem: true, userId: 'usr_system' }; - -/** Three rows whose order by name, by the masked field and by city all differ. */ -const ROWS = [ - { id: 'pk1', name: 'Bravo', [MASKED]: 'AAA111', pk_city: 'Lisbon' }, - { id: 'pk2', name: 'Alpha', [MASKED]: 'CCC333', pk_city: 'Berlin' }, - { id: 'pk3', name: 'Charlie', [MASKED]: 'BBB222', pk_city: 'Austin' }, -]; -const STORED_BY_ID: Record = Object.fromEntries(ROWS.map((r) => [r.id, r[MASKED]])); -const BY_NAME = ['pk2', 'pk1', 'pk3']; -const BY_MASKED = ['pk1', 'pk3', 'pk2']; - -/** The pickers, one per lookup field on the form. Each is parsed by the spec below. */ -const PICKERS = { - c_first: { displayFields: [MASKED, 'name'] }, - c_control: { displayFields: ['name', 'pk_city'] }, - c_only: { displayFields: [MASKED] }, -} as const; - -const MEMBER_SET = PermissionSetSchema.parse({ - name: 'member_default', - label: 'Member', - objects: { '*': { allowRead: true } }, -}); -/** A guest set that admits the object and does not hold the capability. */ -const GUEST_SET = PermissionSetSchema.parse({ - name: 'guest_portal', - label: 'Guest', - objects: { [CONTACT]: { allowRead: true } }, -}); -/** The same guest set holding the capability the masking rule names: the rule is lifted. */ -const GUEST_UNMASK_SET = PermissionSetSchema.parse({ - name: 'guest_portal', - label: 'Guest', - objects: { [CONTACT]: { allowRead: true } }, - systemPermissions: [CAPABILITY], -}); - -const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; - -function createMockServer() { - const noop = () => {}; - return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; -} - -function makeRes() { - const res: any = { - statusCode: 200, - body: undefined as any, - header: () => res, - status: (code: number) => { res.statusCode = code; return res; }, - json: (body: unknown) => { res.body = body; return res; }, - end: () => res, - }; - return res; -} - -/** The stored form, in the flattened registered shape `getMetaItems` serves. */ -const storedForm = { - name: `${INQUIRY}.intake`, - object: INQUIRY, - viewKind: 'form', - config: { - type: 'simple', - data: { provider: 'object', object: INQUIRY }, - sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, - sections: [{ - label: 'Intake', - fields: Object.entries(PICKERS).map(([field, picker]) => ({ field, publicPicker: picker })), - }], - }, -}; - -type Security = Record; -type Harness = { - engine: ObjectQL; - security: Security; - /** Every `engine.find` call on the picked object since boot: its query. */ - finds: () => Array>; - /** Drive the picker as the route is driven, with a given security service. */ - lookup: (field: keyof typeof PICKERS, q?: string, security?: Security) => Promise<{ status: number; body: any }>; -}; - -async function boot(sets: unknown[]): Promise { - const engine = new ObjectQL({ logger: quiet } as any); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.qa.picker-queryable-key-21062', - name: 'Picker queryable key', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [ - { - name: CONTACT, - label: 'Contact', - sharingModel: 'public_read_write', - fields: { - name: { name: 'name', type: 'text' }, - [MASKED]: { - name: MASKED, type: 'text', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: [CAPABILITY], - }, - pk_city: { name: 'pk_city', type: 'text' }, - }, - }, - { - name: INQUIRY, - label: 'Inquiry', - sharingModel: 'public_read_write', - fields: Object.fromEntries(Object.keys(PICKERS).map((f) => [f, { name: f, type: 'lookup', reference: CONTACT }])), - }, - ], - } as never); - await engine.syncSchemas(); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - data: engine, - metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async () => sets, - }, - }; - const ctx: any = { - logger: quiet, - hook: () => {}, - registerService: (name: string, svc: unknown) => { services[name] = svc; }, - replaceService: (name: string, svc: unknown) => { services[name] = svc; }, - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); - await engine.insert(CONTACT, ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); - - const findSpy = vi.spyOn(engine, 'find'); - const finds = () => findSpy.mock.calls - .filter((c) => c[0] === CONTACT) - .map((c) => (c[1] ?? {}) as Record); - - const real = new ObjectStackProtocolImplementation(engine as never) as any; - const protocol: any = { - getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }), - getMetaTypes: async () => [], - getMetaItem: async ({ type, name }: { type: string; name: string }) => - (type === 'object' ? { type, name, item: engine.getSchema(name) } : undefined), - getMetaItems: async ({ type }: { type: string }) => { - if (type === 'view') return [storedForm]; - if (type === 'object') return [engine.getSchema(CONTACT), engine.getSchema(INQUIRY)]; - return []; - }, - findData: (request: unknown) => real.findData(request), - }; - - const security = services.security as Security; - let current: Security | undefined = security; - const rest = new RestServer( - createMockServer() as any, protocol, { api: { requireAuth: false } } as any, - undefined, undefined, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, undefined, undefined, - async () => current, - ); - rest.registerRoutes(); - const route: any = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path.endsWith('/forms/:slug/lookup/:field')); - expect(route, 'the picker route is mounted').toBeDefined(); - - const lookup = async (field: keyof typeof PICKERS, q?: string, as: Security = security) => { - current = as; - const res = makeRes(); - await route.handler({ method: 'GET', params: { slug: SLUG, field }, headers: {}, query: q === undefined ? {} : { q } } as any, res); - current = security; - return { status: res.statusCode, body: res.body }; - }; - return { engine, security, finds, lookup }; -} - -const ids = (body: any): string[] => (body?.data ?? []).map((r: any) => r.id); - -/** Every field a query names as a predicate or an order key. */ -function queriedFields(query: Record): string[] { - const out = new Set(); - const walk = (node: unknown): void => { - if (Array.isArray(node)) { node.forEach(walk); return; } - if (!node || typeof node !== 'object') return; - for (const [k, v] of Object.entries(node as Record)) { - if (!k.startsWith('$')) out.add(k); - walk(v); - } - }; - walk(query.where); - for (const s of query.orderBy ?? []) if (typeof s?.field === 'string') out.add(s.field); - return [...out]; -} - -function expectServedMasked(body: any): void { - for (const row of body?.data ?? []) { - const value = row[MASKED]; - expect(typeof value, `${row.id}: the masked field is served`).toBe('string'); - expect(value, `${row.id}: served masked, not stored`).not.toBe(STORED_BY_ID[row.id]); - expect(String(value)).toContain('*'); - } -} - -/** The engine's own refusal for a query ordered by `fields`, as the picker's context. */ -async function engineRefusal(engine: ObjectQL, fields: readonly string[]) { - const context = { permissions: ['guest_portal'], anonymous: true }; - return engine - .find(CONTACT, { orderBy: fields.map((field) => ({ field, order: 'asc' })), context } as never) - .then(() => null, (e: any) => ({ status: e?.status ?? e?.statusCode, code: e?.code, message: String(e?.message) })); -} - -/** A security service that predates the query answer: the real one, less that method. */ -function withoutQueryAnswer(security: Security): Security { - const { getQueryableFields: _omitted, ...rest } = security; - return rest; -} - -describe('[#21062] the fixture is authorable', () => { - it('every picker on the form is accepted by the spec', () => { - for (const [field, picker] of Object.entries(PICKERS)) { - expect(FormFieldPublicPickerSchema.safeParse(picker).success, field).toBe(true); - } - }); -}); - -for (const [label, sets] of [ - ['the deployment registers no guest set (the context resolves none)', [MEMBER_SET]], - ['the guest set does not hold the capability the rule names', [MEMBER_SET, GUEST_SET]], -] as const) { - describe(`[#21062] a picker whose first display field is masked for its caller — ${label}`, () => { - let h: Harness; - beforeAll(async () => { h = await boot([...sets]); }, 60_000); - afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); - - it('the premise: the security service answers the masked field readable and not queryable', async () => { - const context = { permissions: ['guest_portal'], anonymous: true }; - expect(await h.security.getReadableFields(CONTACT, context)).toContain(MASKED); - expect(await h.security.getQueryableFields(CONTACT, context)).not.toContain(MASKED); - expect(await engineRefusal(h.engine, [MASKED])).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); - }); - - it('serves rows sorted on the next queryable display field, the masked field served masked', async () => { - const { status, body } = await h.lookup('c_first'); - expect(status, JSON.stringify(body)).toBe(200); - expect(ids(body)).toEqual(BY_NAME); - expectServedMasked(body); - }); - - it('searches the next queryable display field', async () => { - const { status, body } = await h.lookup('c_first', 'Brav'); - expect(status, JSON.stringify(body)).toBe(200); - expect(ids(body)).toEqual(['pk1']); - expectServedMasked(body); - }); - - it('never uses the masked field as a key', async () => { - const before = h.finds().length; - await h.lookup('c_first'); - await h.lookup('c_first', 'Brav'); - const asked = h.finds().slice(before); - expect(asked).toHaveLength(2); - for (const query of asked) { - expect(queriedFields(query)).not.toContain(MASKED); - expect(query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); - } - }); - - it('a picker with no queryable display field answers the engine\'s refusal, and the engine is never asked', async () => { - const reference = await engineRefusal(h.engine, PICKERS.c_only.displayFields); - expect(reference).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); - const before = h.finds().length; - for (const q of [undefined, 'A']) { - const { status, body } = await h.lookup('c_only', q); - expect({ status, code: body?.code, message: body?.error }).toEqual({ - status: reference!.status, code: reference!.code, message: reference!.message, - }); - } - expect(h.finds().length - before, 'the engine was asked').toBe(0); - }); - - it('control: a picker with no masked display field keeps its first display field as the key', async () => { - const sorted = await h.lookup('c_control'); - expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); - expect(ids(sorted.body)).toEqual(BY_NAME); - const searched = await h.lookup('c_control', 'Charl'); - expect(ids(searched.body)).toEqual(['pk3']); - }); - }); -} - -describe('[#21062] control: a caller the masking rule is lifted for keeps the first display field as the key', () => { - let h: Harness; - beforeAll(async () => { h = await boot([MEMBER_SET, GUEST_UNMASK_SET]); }, 60_000); - afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); - - it('rows are sorted, and searched, on the first display field, served as stored', async () => { - const sorted = await h.lookup('c_first'); - expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); - expect(ids(sorted.body)).toEqual(BY_MASKED); - for (const row of sorted.body.data) expect(row[MASKED]).toBe(STORED_BY_ID[row.id]); - const searched = await h.lookup('c_first', 'BBB'); - expect(ids(searched.body)).toEqual(['pk3']); - }); -}); - -describe('[#21062] a security service that cannot give the query answer passes over every display field declaring a masking rule', () => { - for (const [label, sets] of [ - ['a caller the rule applies to', [MEMBER_SET, GUEST_SET]], - ['a caller the rule is lifted for (whoever the caller is)', [MEMBER_SET, GUEST_UNMASK_SET]], - ] as const) { - describe(label, () => { - let h: Harness; - beforeAll(async () => { h = await boot([...sets]); }, 60_000); - afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); - - it('the method absent: rows sorted and searched on the next display field', async () => { - const older = withoutQueryAnswer(h.security); - const sorted = await h.lookup('c_first', undefined, older); - expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); - expect(ids(sorted.body)).toEqual(BY_NAME); - const searched = await h.lookup('c_first', 'Brav', older); - expect(ids(searched.body)).toEqual(['pk1']); - }); - - it('the method answering no answer: the same', async () => { - const silent = { ...h.security, getQueryableFields: async () => undefined }; - const sorted = await h.lookup('c_first', undefined, silent); - expect(sorted.status, JSON.stringify(sorted.body)).toBe(200); - expect(ids(sorted.body)).toEqual(BY_NAME); - }); - }); - } -}); - -/** - * The narrowing the changeset declares (`Clause-②: yes (narrowing)`). With a - * security service that cannot give the query answer, the fallback passes over - * every display field declaring a masking rule WHOEVER the caller is, so for a - * caller the rule is lifted for, a picker whose display fields all declare one - * goes from served to refused. Its control is the same caller and picker with - * the service that answers: served. - */ -describe('[#21062] the declared narrowing: a security service without the query answer refuses a picker whose display fields all declare a masking rule', () => { - let h: Harness; - beforeAll(async () => { h = await boot([MEMBER_SET, GUEST_UNMASK_SET]); }, 60_000); - afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); - - it('a caller the rule is lifted for is refused 403 PERMISSION_DENIED, and the engine is never asked', async () => { - const served = await h.lookup('c_only'); - expect(served.status, `control, the service that answers: ${JSON.stringify(served.body)}`).toBe(200); - - for (const older of [withoutQueryAnswer(h.security), { ...h.security, getQueryableFields: async () => undefined }]) { - const before = h.finds().length; - const refused = await h.lookup('c_only', undefined, older); - expect({ status: refused.status, code: refused.body?.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); - expect(h.finds().length - before, 'the engine was asked').toBe(0); - } - }); -}); diff --git a/packages/rest/src/public-form-lookup-picker.test.ts b/packages/rest/src/public-form-lookup-picker.test.ts deleted file mode 100644 index e9af9dfd688..00000000000 --- a/packages/rest/src/public-form-lookup-picker.test.ts +++ /dev/null @@ -1,736 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#7467] The public-lookup capability is REACHABLE end-to-end: a spec-valid - * stored form carrying a `publicPicker` gets a real lookup answer. - * - * `GET /forms/:slug/lookup/:field` has always gated the anonymous picker on a - * `publicPicker` block — and until #7467 that key was declared in no schema, - * so `saveMetaItem` (which validates through `ViewMetadataSchema`) refused - * every form carrying one with a 422 and the route's entire picker branch was - * live code no authoring path could turn on. PR #7468's stored-row suite pins - * that boundary from the other side ("STILL 403 — for a different reason"); - * THIS file is the flip's content: the same real write path now persists the - * picker, and the same real route handler answers with data. - * - * ## What is real here and what is stubbed, exactly - * - * The form body fed to the routes is the body a REAL `saveMetaItem` persisted - * into a stub repository's `sys_metadata` row, read back out of that row — - * that write path validates through the REAL `ViewMetadataSchema` from - * `@objectstack/spec`, which is precisely the door that refused the picker - * before #7467. The route handlers are the real `RestServer` registrations. - * The data engine under `findData` is stubbed (this suite pins the route's - * request composition and response projection, not a driver), and the reader - * is stubbed as in `public-form-routes.test.ts` — the ADR-0087 stored-row - * conversion chain is a different seam, untouched by this card. - */ -import { describe, expect, it, vi } from 'vitest'; -// The engine-double contract (#4434 / #5619): a fake engine's update/delete -// must be exactly as strict as ObjectQL's dispatch, or a dead route ships with -// its suite green. Both predicates live in metadata-core. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -// [#13137] The instrument for the three-level control at the bottom of this -// file — the REAL `FieldSchema`, not a restatement of it. -import { FieldSchema } from '@objectstack/spec/data'; -import { RestServer } from './rest-server.js'; - -// ─── the real save path (the seam that refused the picker before #7467) ───── - -/** The slice of the engine the `sys_metadata` write path touches. */ -function stubEngine() { - const rows: Array> = []; - let nextId = 0; - return { - rows, - engine: { - async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); return null; }, - async find() { return rows.slice(); }, - async insert(table: string, data: Record) { - if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; - nextId += 1; - rows.push({ id: `r_${nextId}`, ...data }); - return { id: `r_${nextId}` }; - }, - async update(_t: string, data: Record, opts: { where: Record }) { - assertEngineUpdateDispatch(data, opts); - return { id: null }; - }, - async delete(_t: string, opts: { where: Record }) { - assertEngineDeleteDispatch(opts); - return { deleted: 0 }; - }, - registry: { registerItem: () => {}, registerObject: () => {}, listItems: () => [] }, - } as any, - }; -} - -/** - * Save a view through the real `saveMetaItem` and return the body the - * `sys_metadata` row holds — what a consumer of that row will read. - */ -async function persistedBody(item: unknown): Promise { - const { engine, rows } = stubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - const result = await protocol.saveMetaItem({ type: 'view', name: 'lead.contact', item }); - expect(result.success, JSON.stringify(result)).toBe(true); - const row = rows.find((r) => r.type === 'view'); - expect(row, 'the save persisted no view row').toBeDefined(); - return JSON.parse(row!.metadata); -} - -// ─── the fixture an author writes ─────────────────────────────────────────── - -/** - * The picker under test: every key is one the route reads, nothing more. - * `object` is declared here to exercise the explicit override branch. It used - * to be declared because it was the ONLY branch that worked — the route's - * fallback read the legacy field-def spellings and not the canonical - * `reference` (#7486, fixed). The omitted-`object` case now has its own suite - * at the bottom of this file; leaving that path untested would leave the fix - * unguarded at the level users actually hit. - */ -const PICKER = { - displayFields: ['name', 'email'], - maxResults: 10, - filter: [{ field: 'is_active', operator: 'equals', value: true }], - object: 'sys_user', -}; - -const studioForm = (fields: unknown[]) => ({ - name: 'lead.contact', - object: 'lead', - viewKind: 'form', - label: 'Contact us', - config: { - type: 'simple', - data: { provider: 'object', object: 'lead' }, - sharing: { allowAnonymous: true, publicLink: '/forms/contact' }, - sections: [{ label: 'About you', fields }], - }, -}); - -const leadObject = { - name: 'lead', - label: 'Lead', - fields: { - id: { type: 'text' }, - company: { type: 'text', label: 'Company' }, - owner: { type: 'lookup', reference: 'sys_user', label: 'Owner' }, - }, -}; - -// ─── the real routes ──────────────────────────────────────────────────────── - -function mockServer() { - return { - get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), - use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), - }; -} - -function mockRes() { - const res: any = { statusCode: 200, body: undefined }; - res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); - res.json = vi.fn((b: any) => { res.body = b; return res; }); - res.header = vi.fn(() => res); - res.end = vi.fn(() => res); - return res; -} - -/** - * Mount the real routes over a protocol that serves the STORED view body. - * `objectDef` defaults to the canonical `leadObject`; the #7486 suite passes - * variants to cover the legacy spellings the route's fallback chain still - * reads — spellings `FieldSchema` REFUSES (#13137), so they can only ever have - * been STORED, never authored through the spec. - */ -function routesOver(storedView: any, foundRows: any[], objectDef: any = leadObject) { - const findData = vi.fn().mockResolvedValue({ data: foundRows }); - const protocol: any = { - getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), - getMetaTypes: vi.fn().mockResolvedValue([]), - getMetaItems: vi.fn(async ({ type }: { type: string }) => { - if (type === 'view') return [storedView]; - if (type === 'object') return [objectDef]; - return []; - }), - createData: vi.fn().mockResolvedValue({ object: 'lead', id: 'rec_1', record: {} }), - findData, - }; - const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); - rest.registerRoutes(); - const lookup = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path.endsWith('/forms/:slug/lookup/:field'))!; - return { findData, lookup }; -} - -describe('#7467 a spec-valid stored form carrying a publicPicker reaches the lookup route', () => { - it('the declaration survives the real save — a 422 here is the pre-#7467 gap reopening', async () => { - // Before #7467 this exact save failed: `ViewMetadataSchema` reported - // `unrecognized_keys` on `publicPicker` and no row was written. The - // whole capability hangs on this assertion, which is why it stands - // alone rather than as a side effect of the route cases below. - const stored = await persistedBody(studioForm([{ field: 'owner', publicPicker: PICKER }])); - expect(stored.config.sections[0].fields[0].publicPicker).toMatchObject({ - displayFields: ['name', 'email'], - maxResults: 10, - object: 'sys_user', - }); - }); - - it('…and the real lookup handler answers with projected data, not 403', async () => { - const stored = await persistedBody(studioForm([{ field: 'owner', publicPicker: PICKER }])); - const { findData, lookup } = routesOver(stored, [ - // The driver "returns" a column the projection must strip: never - // trust that the engine respected `select` on an anonymous surface. - { id: 'usr_1', name: 'Ada', email: 'ada@example.com', password_hash: 'LEAK' }, - { id: 'usr_2', name: 'Adele', email: 'adele@example.com' }, - ]); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: { q: 'ad' } } as any, res); - - expect(res.statusCode).toBe(200); - expect(res.body.data).toEqual([ - { id: 'usr_1', name: 'Ada', email: 'ada@example.com' }, - { id: 'usr_2', name: 'Adele', email: 'adele@example.com' }, - ]); - expect(res.body.displayFields).toEqual(['name', 'email']); - - // The query the route composed reads the STORED picker, key for key: - // the declared object override, the declared cap, the declared filter - // rows ahead of the visitor's search predicate, id + displayFields - // projection, offset pinned to 0 (no anonymous pagination). - // - // [#16337] The KEYS are the canonical QueryAST ones (`where` / `fields` - // / `orderBy`); until then the route spelled them `filters` / `select` / - // `sort`, wire aliases the normalizer folds onto exactly these. - // - // [#16581] The VALUE on `where` is the part that moved. It used to be - // the `ViewFilterRule` rows verbatim — the dialect - // `FormFieldPublicPickerSchema.filter` declares — which the ingress - // refuses with `400 INVALID_FILTER`, so this endpoint answered 400 for - // every non-empty search. The route now LOWERS them to the - // `FilterArray` grammar the parser reads, and the declared conjunction - // is written down rather than left to the list form's implicit AND. - // ⚠️ `findData` is stubbed in this suite, so this remains a COMPOSITION - // pin and cannot say the value is served: that is measured against the - // real normalizer in `public-form-lookup-filter-lowering.test.ts`, - // whose §3 keeps the control that the parser itself was NOT loosened. - expect(findData).toHaveBeenCalledTimes(1); - const call = findData.mock.calls[0][0]; - expect(call.object).toBe('sys_user'); - expect(call.query.limit).toBe(10); - expect(call.query.offset).toBe(0); - expect(call.query.fields).toEqual(['id', 'name', 'email']); - // [#7485] Ordering is fixed, not authorable: first display field, - // ascending. The route's `picker.sort ??` read is retired. - expect(call.query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); - expect(call.query.where).toEqual([ - 'and', - ['is_active', 'equals', true], - ['name', 'contains', 'ad'], - ]); - expect(call.context.anonymous).toBe(true); - }); - - it('GUARD: a stored form whose field declares NO picker still answers 403 LOOKUP_NOT_PUBLIC', async () => { - // The opt-in stays an opt-in. Green before and after #7467 — declaring - // the key must not have widened the default. (This is the surviving - // half of PR #7468's "STILL 403" boundary pin: picker-less is now the - // only reason left.) - const stored = await persistedBody(studioForm(['company', { field: 'owner' }])); - const { findData, lookup } = routesOver(stored, []); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - expect(res.statusCode).toBe(403); - expect(res.body.code).toBe('LOOKUP_NOT_PUBLIC'); - expect(findData).not.toHaveBeenCalled(); - }); -}); - -// ─── [#7485] the retired fifth read ───────────────────────────────────────── - -/** - * Run a save and report a single verdict, however the door refuses: a - * `{ success: false }` result and a thrown validation error are the same - * answer here (the row is not written), and this suite pins the answer, not - * which of the two spellings the protocol currently uses. - */ -async function saveVerdict(item: unknown): Promise<{ ok: boolean; detail: string }> { - const { engine, rows } = stubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; - try { - const result = await protocol.saveMetaItem({ type: 'view', name: 'lead.contact', item }); - const wrote = rows.some((r) => r.type === 'view'); - return { ok: result?.success === true && wrote, detail: JSON.stringify(result) }; - } catch (error: any) { - return { ok: false, detail: String(error?.message ?? error) }; - } -} - -describe('#7485 publicPicker.sort is retired — not declarable, and not read', () => { - it('the schema REFUSES a new form authoring publicPicker.sort — no row is written', async () => { - // Half one of the pin, from the authoring side. `sort` was never in - // `FormFieldPublicPickerSchema` (#7467), and #7485 chose to keep it - // that way by removing the route's read rather than adding the key — - // so the strict block (ADR-0089 D3a) is the enforcement, and it must - // stay loud. `packages/spec/src/ui/view-public-picker.test.ts` pins the - // same refusal at the schema; this pins it at the real write path. - const withSort = await saveVerdict(studioForm([{ - field: 'owner', - publicPicker: { ...PICKER, sort: [{ field: 'email', order: 'desc' }] }, - }])); - expect(withSort.ok, `the save was expected to fail: ${withSort.detail}`).toBe(false); - - // The control, and the reason this pin does not match on the error - // text: through `ViewMetadataSchema`'s union the failing ViewItem - // branch loses to the container branch's diagnostic, so the reported - // message names `viewKind`/`config` rather than `sort` (a pre-existing - // union-diagnostic wart, filed separately — not caused by #7485). The - // byte-identical form MINUS `sort` saving proves `sort` is the sole - // cause, which is what this test is actually about. - const withoutSort = await saveVerdict(studioForm([{ field: 'owner', publicPicker: PICKER }])); - expect(withoutSort.ok, `the control save must succeed: ${withoutSort.detail}`).toBe(true); - }); - - it('a PRE-SCHEMA stored row still carrying sort is IGNORED, not an error — and the query keeps the fixed default', async () => { - // Half two, from the stored-row side, and the reason this file rather - // than the spec suite owns it: rows written before #7467 declared the - // block never went through `ViewMetadataSchema`, so one can carry a - // `sort` the schema would refuse today. The route must neither honor it - // (that is the read #7485 retired) nor choke on it (a 500 on an - // anonymous surface would be a regression the removal caused). It is - // dead data: read past, answered 200, ordering unchanged. - const stored = await persistedBody(studioForm([{ field: 'owner', publicPicker: PICKER }])); - stored.config.sections[0].fields[0].publicPicker.sort = [{ field: 'email', order: 'desc' }]; - - const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(200); - expect(res.body.data).toEqual([{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); - // The stored `{ field: 'email', order: 'desc' }` reaches `findData` - // nowhere: the fixed default is the only ordering the route composes. - expect(findData).toHaveBeenCalledTimes(1); - expect(findData.mock.calls[0][0].query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); - }); - - it('…and the fixed sort tracks displayFields[0], including the no-displayFields default', async () => { - // The ordering is not a constant — it is "first display field, - // ascending". An empty block defaults displayFields to ['name'], so the - // sort follows to `name`; a declared list sorts by its first entry. - // Pinning both keeps a future refactor from freezing the field name. - const stored = await persistedBody(studioForm([{ field: 'owner', publicPicker: { object: 'sys_user' } }])); - const { findData, lookup } = routesOver(stored, []); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, mockRes()); - expect(findData.mock.calls[0][0].query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); - - const stored2 = await persistedBody(studioForm([{ - field: 'owner', - publicPicker: { displayFields: ['email', 'name'], object: 'sys_user' }, - }])); - const second = routesOver(stored2, []); - await second.lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, mockRes()); - expect(second.findData.mock.calls[0][0].query.orderBy).toEqual([{ field: 'email', order: 'asc' }]); - }); -}); - -// ─── [#7486] the fallback resolution, against CANONICAL metadata ──────────── - -/** - * The defect this suite pins: the route's fallback chain read only the LEGACY - * field-def spellings (`referenceTo` / `target` / `options.objectName`), not - * one of which `packages/spec/src/data/field.zod.ts` ACCEPTS. ⛔ [#13137] It - * does not fold them onto `reference` either — an earlier version of this - * paragraph claimed it did. `FieldSchema` is a `strictObject`, so - * `referenceTo` / `target` are REFUSED by name; the `aliases` table only adds - * *"Did you mean …"* to that refusal, because `strictObject` consults it solely - * from the `unrecognized_keys` path. Pinned three ways at the bottom of this - * file. A parsed object schema therefore carried NONE of the keys the route - * read — the chain resolved `undefined` and a well-formed form got - * `500 LOOKUP_TARGET_MISSING`, making `publicPicker.object` de-facto REQUIRED - * while the schema and docs present it as optional. - * - * ⛔ [#12920] What the fix must NOT be, settled by ruling: the repair was - * never "read the legacy spellings too". The route read `reference` FIRST and - * three legacy spellings after it until 2026-09-09, when the tolerant tail was - * retired — director seat summon #20, decision batch #107 item 5, maintainer - * verbatim 「其他同意」 = option A, executing the 2026-08-30 stance, verbatim - * 「折叠即契约」. This route now reads `reference` and nothing else, and the - * cases below pin that. - * - * Every case below omits `object` deliberately: that is the axis under test. - * The suite above covers the override branch and must stay that way — between - * them the two branches of the resolution are both pinned. - */ -describe('#7486 the picker target resolves from the field definition when `object` is omitted', () => { - /** The picker an author writes when they take the docs at their word. */ - const NO_OBJECT_PICKER = { displayFields: ['name', 'email'], maxResults: 10 }; - - const savedWithoutObject = () => persistedBody(studioForm([{ field: 'owner', publicPicker: NO_OBJECT_PICKER }])); - - it('a CANONICAL `{ type: lookup, reference: sys_user }` field resolves with no `object` override', async () => { - // The headline case, and the one the platform actually produces: this - // exact request answered 500 LOOKUP_TARGET_MISSING before #7486. - const stored = await savedWithoutObject(); - expect(stored.config.sections[0].fields[0].publicPicker.object).toBeUndefined(); - - const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: { q: 'ad' } } as any, res); - - expect(res.statusCode).toBe(200); - expect(res.body.data).toEqual([{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); - // Resolved from `leadObject.fields.owner.reference` — not from the - // picker, which declares no object at all. - expect(findData).toHaveBeenCalledTimes(1); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); - }); - - // ⛔ [#13137] These are spellings `FieldSchema` REFUSES. There is no fold, - // so there is no "pre-fold row" for one of these to be — an earlier version - // of this comment said there was, and #12920 cited that sentence as its - // strongest surviving evidence that stored legacy rows exist. It carried - // ZERO observational content about stored data, and neither do these cases. - // - // ⭐ [#12920] RULED, and these cases now pin the ruling. The open - // production census this block used to suspend judgement on — "is a stored - // alias-spelled row reachable in a live deployment?" — was answered by the - // maintainer, not by a scan: NONE to preserve, consistent with the - // 2026-08-27 startup-phase principle (no staged transitions) and with the - // in-tree census (zero producers, zero relation fields spelling the target - // with an alias, positive controls fired). ⇒ the route's four-spelling - // tolerant chain retired; a stored row spelling the target the old way is - // a PRODUCER defect, and this route refuses it like every other consumer. - // - // What is true and unchanged: such a def never came through `FieldSchema`, - // and the serving read path replays ADR-0087 conversions - // (`applyConversionsToStoredItem`) without any schema validation, so it - // WOULD reach the route verbatim. These cases pin what the route does with - // one — refuse it, loudly, with the service never called. - // - // ⚠️ Direction matters: all three are RED against the four-arm chain (it - // answers 200 and searches `sys_user`) and green against the one-key read. - // ⛔ A pin that passed in both states would be no evidence at all. - const LEGACY_DEFS: Array<[string, Record]> = [ - ['referenceTo', { type: 'lookup', referenceTo: 'sys_user' }], - ['target', { type: 'lookup', target: 'sys_user' }], - ['options.objectName', { type: 'lookup', options: { objectName: 'sys_user' } }], - ]; - for (const [spelling, ownerDef] of LEGACY_DEFS) { - it(`a stored row spelling the target the LEGACY way (\`${spelling}\`) is NOT resolved`, async () => { - const stored = await savedWithoutObject(); - const legacyObject = { ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } }; - // The engine is loaded with a row a resolving route WOULD return, - // so the red state is a 200 carrying data, not an empty 200. - const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], legacyObject); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - // ⛔ Both halves. The status alone cannot separate "refused the - // alias" from "resolved it and the search came back empty"; only - // the never-called half says the target was never resolved. - expect(findData).not.toHaveBeenCalled(); - }); - } - - it('the canonical `reference` still resolves on a def that ALSO carries a legacy spelling', async () => { - // A partially-migrated def: the canonical key present, an alias beside - // it. `reference` is read and the alias is not consulted at all — - // neither to shadow it nor to break it. Before #12920 this pinned - // head-of-chain ORDER; with one key left it pins that narrowing the - // read did not make a canonical def collateral damage. - const stored = await savedWithoutObject(); - const bothObject = { - ...leadObject, - fields: { ...leadObject.fields, owner: { type: 'lookup', reference: 'sys_user', referenceTo: 'stale_legacy' } }, - }; - const { findData, lookup } = routesOver(stored, [], bothObject); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(200); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); - }); - - it('GUARD: a field def carrying NO target at all still answers 500 LOOKUP_TARGET_MISSING', async () => { - // The error did not become unreachable — it became RARE. Widening the - // chain must not make an unresolvable picker silently search nothing. - const stored = await savedWithoutObject(); - const targetless = { ...leadObject, fields: { ...leadObject.fields, owner: { type: 'lookup' } } }; - const { findData, lookup } = routesOver(stored, [], targetless); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - expect(findData).not.toHaveBeenCalled(); - }); -}); - -// ─── [#13137] the spec's alias table REFUSES, it does not fold ───────────── - -/** - * The three-level control, pinned so the prose above cannot rot back. - * - * Two levels cannot carry this. "`FieldSchema` rejects `referenceTo`" reads - * equally well as *not measured*, and a blanket "everything is rejected" says - * nothing about the alias table at all. Three levels separate the claims: - * ACCEPT (the canonical key) · REFUSE **with** a rename hint (an alias-table - * entry) · REFUSE **without** one (a key on no list). Only the third makes the - * second mean something. - * - * The mechanism, if this ever reads surprising: `strictObject`'s `aliases` - * table is consulted ONLY from the `unrecognized_keys` path - * (`packages/spec/src/shared/strict-object.ts`), so an entry can only ever - * decorate a REFUSAL. ⛔ It is not a normaliser, on this surface or any other — - * three separate seats read the name `aliases` as "fold" on two different - * schemas in one day, which is what this file's prose used to say too. - * - * ⛔ SCOPE: this pins the SPEC's behaviour and nothing else. It asserts - * nothing about whether any stored row spells a target the legacy way. That - * question — #12920's production census — was closed by the maintainer on - * 2026-09-09 ("none to preserve"), ⛔ not from here: it was never answerable - * from a schema pin, and no assertion below may be cited as evidence for it in - * either direction. - */ -describe('#13137 `FieldSchema` REFUSES the legacy target spellings, it does not fold them', () => { - /** `type` is the only required key on `FieldSchema`; everything else is the axis under test. */ - const parseField = (extra: Record) => FieldSchema.safeParse({ type: 'lookup', ...extra }); - const messagesOf = (result: { success: boolean; error?: { issues: Array<{ code: string; message: string }> } }) => - (result.error?.issues ?? []).map((i) => i.message).join('\n'); - const codesOf = (result: { success: boolean; error?: { issues: Array<{ code: string }> } }) => - (result.error?.issues ?? []).map((i) => i.code); - - it('LEVEL 1 — POSITIVE CONTROL: the canonical `reference` is ACCEPTED', () => { - // Without this the two refusals below are indistinguishable from a - // schema that refuses its own canonical key, i.e. a broken instrument. - const result = parseField({ reference: 'sys_user' }); - expect(result.success).toBe(true); - expect(result.success && result.data.reference).toBe('sys_user'); - }); - - it('LEVEL 2 — NEGATIVE CONTROL: a key on no list is refused `unrecognized_keys` with NO rename hint', () => { - const result = parseField({ zzz_not_a_key: 'sys_user' }); - expect(result.success).toBe(false); - expect(codesOf(result)).toContain('unrecognized_keys'); - expect(messagesOf(result)).toContain('`zzz_not_a_key`'); - expect(messagesOf(result)).not.toContain('Did you mean'); - }); - - // LEVEL 3 — all five spellings the alias table files under `reference`. - // ⛔ `success` is `false` for every one of them: that is the whole card. - for (const alias of ['relatedTo', 'referenceTo', 'target', 'targetObject', 'lookupObject']) { - it(`LEVEL 3 — \`${alias}\` is REFUSED with a rename hint onto \`reference\` — NOT folded onto it`, () => { - const result = parseField({ [alias]: 'sys_user' }); - // Refused, not rewritten. If this ever flips to `true`, the alias - // table has become a normaliser and every "does not fold" sentence - // in this file and in `rest-server.ts` needs rewriting again. - expect(result.success).toBe(false); - expect(codesOf(result)).toContain('unrecognized_keys'); - // The hint is what separates level 3 from level 2 — it names the - // canonical key the author should have written instead. - expect(messagesOf(result)).toContain(`Did you mean \`${alias}\` → \`reference\`?`); - }); - } -}); - -/** - * [#18550] The picker's field-def fallback must refuse a `reference` carrier it - * cannot READ, rather than reporting the target as MISSING. - * - * This was one of the measured residue sites of ruling letter E item 2 on - * #18095: `referenceObject = def?.reference` read the carrier raw, INSIDE the - * metadata fetch's `catch {}`. Two things followed from that, and both are - * pinned below. - * - * - An object-valued carrier is TRUTHY, so it passed the - * `if (!referenceObject)` gate and was forwarded verbatim as - * `query.object` into `findData` — the route asked the data layer to search - * an object whose name was an object. - * - Moving the read through the arbiter alone would not have been enough: - * inside that `catch` the refusal would have been swallowed and the route - * would have answered `500 LOOKUP_TARGET_MISSING` — "no target is declared" - * — for a def that declares one this reader cannot read. The two want - * different fixes from whoever owns the metadata, so the field def is - * hoisted out of the swallow and the carrier is read after it. - * - * Absence keeps its answer: `undefined`, `null` and `''` all still reach - * `LOOKUP_TARGET_MISSING`, which is the envelope this route has always used to - * say "nothing names the target". - */ -describe('#18550 an UNREADABLE `reference` carrier is refused, not reported as a missing target', () => { - const NO_OBJECT_PICKER = { displayFields: ['name', 'email'], maxResults: 10 }; - const savedWithoutObject = () => persistedBody(studioForm([{ field: 'owner', publicPicker: NO_OBJECT_PICKER }])); - const ownerDefIs = (ownerDef: unknown) => ({ ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } }); - - it('an object-valued carrier does NOT answer LOOKUP_TARGET_MISSING, and never reaches findData', async () => { - const stored = await savedWithoutObject(); - // The engine holds a row a resolving route WOULD return, so the red - // state is a 200 carrying data rather than an empty 200. - const { findData, lookup } = routesOver( - stored, - [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], - ownerDefIs({ type: 'lookup', reference: { object: 'sys_user' }, label: 'Owner' }), - ); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - // ⛔ The load-bearing NEGATIVE, and the whole point of hoisting the def - // out of the fetch's swallow: an unreadable carrier must not be - // reported as an absent one. - expect(res.body.code).not.toBe('LOOKUP_TARGET_MISSING'); - // What it IS instead, measured: the handler's outer `catch` classifies - // the throw and `logError`s it, so the carrier's unreadability reaches - // the operator's log and the caller gets the sanitised fault envelope - // (#5437/#7543 — a crash's `TypeError: …` text is never disclosed to - // the caller). ⚠️ The refusal is loud in the LOG; on the wire it is a - // 500 that is merely DISTINGUISHABLE from the missing-target 500. - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('INTERNAL_ERROR'); - // …and the object-valued carrier is never forwarded as `query.object`. - expect(findData).not.toHaveBeenCalled(); - }); - - it('control: an ABSENT carrier is STILL LOOKUP_TARGET_MISSING — the envelope absence has always had', async () => { - const stored = await savedWithoutObject(); - const { findData, lookup } = routesOver( - stored, - [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], - ownerDefIs({ type: 'lookup', label: 'Owner' }), - ); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - expect(findData).not.toHaveBeenCalled(); - }); - - it('control: a NULL carrier is absence too (`StrictField` declares it nullable) — same envelope, no throw', async () => { - const stored = await savedWithoutObject(); - const { lookup } = routesOver(stored, [], ownerDefIs({ type: 'lookup', reference: null, label: 'Owner' })); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - }); - - it('control: the canonical STRING carrier still resolves and answers 200 — the routing did not break the live path', async () => { - const stored = await savedWithoutObject(); - const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }]); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - - expect(res.statusCode).toBe(200); - expect(findData).toHaveBeenCalledTimes(1); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); - }); -}); - -/** - * [#19289] A `publicPicker` on a spec-complete `{ type: 'user' }` field must be - * ANSWERED — the second of the two CONFIRMED defects of the implicit-target - * census, and the LOUD one that graded this card `p1`. - * - * `IMPLICIT_REFERENCE_TARGETS` (`packages/spec/src/data/field-value.zod.ts`) - * declares a `user` field's target "a CONSTANT OF THE TYPE" (`sys_user`) and - * metadata authored without `reference` "fully specified, not - * under-specified". This read has no type gate at all — it resolves whatever - * field the picker names — so a `user` field reaches it, and #18550 pointed it - * at `referenceCarrierOf`, which answers what the CARRIER says. The carrier is - * absent on such a field, so the route answered - * `500 LOOKUP_TARGET_MISSING`: opening a reference picker on a "responsible - * person" column returned an error page for metadata the published contract - * already calls complete. - * - * ⛔ This is NOT a re-widening of #12920's narrowing, and the controls below - * are what say so rather than the prose: the rejected aliases still resolve - * NOTHING, and the alias suite above still passes unchanged. What is deleted - * is a mistaken REFUSAL of metadata the contract declares complete — the - * target comes from the spec's own constant, never from a second spelling. - */ -describe('#19289 a `user` field needs no carrier — the picker answers instead of 500ing', () => { - const NO_OBJECT_PICKER = { displayFields: ['name', 'email'], maxResults: 10 }; - const savedWithoutObject = () => persistedBody(studioForm([{ field: 'owner', publicPicker: NO_OBJECT_PICKER }])); - const ownerDefIs = (ownerDef: unknown) => ({ ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } }); - - const answerFor = async (ownerDef: unknown) => { - const stored = await savedWithoutObject(); - const { findData, lookup } = routesOver( - stored, - [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], - ownerDefIs(ownerDef), - ); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - return { res, findData }; - }; - - it('THE DEFECT: `{ type: "user" }` with no `reference` answers 200 over `sys_user`, not 500', async () => { - const { res, findData } = await answerFor({ type: 'user', label: 'Owner' }); - expect(res.body.code).not.toBe('LOOKUP_TARGET_MISSING'); - expect(res.statusCode).toBe(200); - // ⛔ Not just "no longer 500": the route must query the object the TYPE - // names. A 200 over the wrong object is the same defect wearing a - // success code. - expect(findData).toHaveBeenCalledTimes(1); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); - }); - - it('the two legal spellings of one fully-specified field answer identically', async () => { - // `reference: 'sys_user'` MATERIALIZES the constant, it does not supply - // it, so writing it and omitting it are the same metadata. - const implicit = await answerFor({ type: 'user', label: 'Owner' }); - const explicit = await answerFor({ type: 'user', reference: 'sys_user', label: 'Owner' }); - expect(implicit.res.statusCode).toBe(explicit.res.statusCode); - expect(implicit.findData.mock.calls[0][0].object).toBe(explicit.findData.mock.calls[0][0].object); - }); - - // ── The boundary: `user` is the ONLY member of `IMPLICIT_REFERENCE_TARGETS`. - it.each([ - ['lookup', 'lookup'], - ['master_detail', 'master_detail'], - ])('control: a carrier-less `%s` is STILL LOOKUP_TARGET_MISSING — nothing supplies a target for it', async (_l, type) => { - const { res, findData } = await answerFor({ type, label: 'Owner' }); - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - expect(findData).not.toHaveBeenCalled(); - }); - - it('control: the #12920 narrowing HOLDS — a `user` field spelling the target `referenceTo` resolves it from the TYPE, never from the alias', async () => { - // The discriminating case. `referenceTo: 'zzz_aliased_object'` is a - // rejected alias: if it were being folded, `findData` would be asked - // for `zzz_aliased_object`. It is asked for `sys_user` — the type's own - // constant — so the alias contributed NOTHING. - const { res, findData } = await answerFor({ type: 'user', referenceTo: 'zzz_aliased_object', label: 'Owner' }); - expect(res.statusCode).toBe(200); - expect(findData.mock.calls[0][0].object).toBe('sys_user'); - expect(findData.mock.calls[0][0].object).not.toBe('zzz_aliased_object'); - }); - - it('control: a `lookup` spelling the target `referenceTo` STILL resolves nothing — the alias is still refused', async () => { - const { res, findData } = await answerFor({ type: 'lookup', referenceTo: 'zzz_aliased_object', label: 'Owner' }); - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('LOOKUP_TARGET_MISSING'); - expect(findData).not.toHaveBeenCalled(); - }); - - it('control: an UNREADABLE carrier on a `user` field still REFUSES — the implicit target is not a fallback that swallows it', async () => { - // `referenceTargetOf` reads the carrier through `referenceCarrierOf` - // BEFORE it judges the type, so #18550's refusal is untouched: a broken - // carrier does not quietly become `sys_user`. - const { res, findData } = await answerFor({ type: 'user', reference: { object: 'sys_user' }, label: 'Owner' }); - expect(res.statusCode).toBe(500); - expect(res.body.code).toBe('INTERNAL_ERROR'); - expect(res.body.code).not.toBe('LOOKUP_TARGET_MISSING'); - expect(findData).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/rest/src/public-form-routes.stored-row.test.ts b/packages/rest/src/public-form-routes.stored-row.test.ts index 73c415dcf3d..d721ef5aac0 100644 --- a/packages/rest/src/public-form-routes.stored-row.test.ts +++ b/packages/rest/src/public-form-routes.stored-row.test.ts @@ -14,14 +14,15 @@ * - `GET /forms/:slug` published an EMPTY field schema (#6601) * - `POST /forms/:slug/submit` computed an empty `allowedFields` and * refused the submit outright (#6920) - * - `GET /forms/:slug/lookup/:field` answered 403 for every field (#3022) + * - the anonymous lookup-picker route answered 403 for every field (#3022) * * The first two clear on the stored-row path and are pinned below. The THIRD - * cleared later, in two steps: the fold reached its `sections` walk on this - * card, and #7467 made the route's `publicPicker` opt-in spec-declarable (it - * used to be refused 422 at `saveMetaItem`, so no stored row could carry one). - * What was pinned here as a `BOUNDARY: STILL 403` case is now the flipped - * pin below: a stored form carrying a picker gets a real lookup answer. + * cleared later (#7467 made its per-field opt-in declarable, and a flipped pin + * here showed a stored form getting a real lookup answer) and then left + * entirely: [#21180] ruling E on #21079 retired the anonymous picker and + * deleted the route, so there is no third route to pin — the resolve route's + * unconditional strip of lookup / master_detail / user fields is pinned in + * `public-form-routes.test.ts`. * * ⛔ The fix is NOT `?? match.form?.groups` here. The #6926 guardrail stands: a * lenient consumer is where AI-authored metadata errors hide, and it leaves the @@ -114,15 +115,7 @@ async function persistedBody(name: string, item: unknown): Promise { const SHARING = { allowAnonymous: true, publicLink: '/forms/contact' }; const DATA = { provider: 'object', object: 'lead' }; -/** - * The section every case declares. - * - * It carries NO `publicPicker`, which — since #7467 declared that key — is a - * choice rather than a necessity (when this file was written the schema - * refused the key outright, so no stored row could carry one). A picker-less - * field keeps the lookup route at 403, which the flipped case below contrasts - * against its own picker-carrying fixture. - */ +/** The section every case declares. */ const SECTION = { label: 'About you', fields: ['company', { field: 'owner' }], @@ -184,11 +177,6 @@ function routesOver(storedView: any) { return []; }), createData, - // The lookup route's search, once it gets past the 403. The route reads - // `result.data` (or `result.items`) — the flipped #7467 case below - // asserts the projected rows, so the stub answers in that shape. - queryData: vi.fn().mockResolvedValue({ data: [{ id: 'usr_1', name: 'Ada' }] }), - findData: vi.fn().mockResolvedValue({ data: [{ id: 'usr_1', name: 'Ada' }] }), }; const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); @@ -199,7 +187,6 @@ function routesOver(storedView: any) { createData, resolve: find('GET', '/forms/:slug'), submit: find('POST', '/forms/:slug/submit'), - lookup: find('GET', '/forms/:slug/lookup/:field'), }; } @@ -232,46 +219,8 @@ describe('#7134 a Studio-saved form authored with `groups` no longer degrades on expect(createData.mock.calls[0][0].data).toEqual({ company: 'Acme', owner: 'usr_1' }); }); - it('FLIPPED [#7467]: the lookup route answers — a stored form carrying a publicPicker gets real data', async () => { - // This case was born as `BOUNDARY: the lookup route is STILL 403 — for - // a different reason`: #7134 listed the lookup route's blanket 403 as - // the third degradation, the fold reached its `sections` walk (the two - // routes above are the same walk, and they changed), but the route's - // `publicPicker` opt-in was declared in NO schema — `ViewMetadataSchema` - // is strict, so a form carrying one was refused 422 by `saveMetaItem` - // and could never reach a row. The original assertion said so and was - // annotated to be revisited the day a spec-side home landed. - // - // #7467 landed it (maintainer ruling: declare — `FormFieldSchema. - // publicPicker`, mirroring exactly what this route reads), so this is - // the revisit: the SAME real write path now persists the picker, the - // SAME real route handler gets past the 403, and the third degradation - // clears end-to-end. `object` is declared on the picker because the - // route's field-def fallback reads only legacy spellings — #7486. - // - // The picker-less half of the old pin is not lost: a stored form whose - // field declares no picker still answers 403, pinned in - // `public-form-lookup-picker.test.ts` (the opt-in stays an opt-in). - const withPicker = { - ...studioForm('groups'), - config: { - type: 'simple', data: DATA, sharing: SHARING, - groups: [{ - label: 'About you', - fields: ['company', { field: 'owner', publicPicker: { displayFields: ['name'], object: 'sys_user' } }], - }], - }, - }; - const { lookup } = routesOver(await persistedBody('lead.contact', withPicker)); - const res = mockRes(); - await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res); - expect(res.statusCode).toBe(200); - expect(res.body.data).toEqual([{ id: 'usr_1', name: 'Ada' }]); - expect(res.body.displayFields).toEqual(['name']); - }); - it('the stored row itself is the canonical spelling — nothing here reads `groups`', async () => { - // The claim under all three routes above, stated directly: the fix is + // The claim under both routes above, stated directly: the fix is // that the ROW changed, not that `rest-server.ts` learned a second key. const stored = await persistedBody('lead.contact', studioForm('groups')); expect(stored.config).not.toHaveProperty('groups'); diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts index 17b864bef91..f7889c613c5 100644 --- a/packages/rest/src/public-form-routes.test.ts +++ b/packages/rest/src/public-form-routes.test.ts @@ -4,8 +4,8 @@ // anchor enforcement. The submit route must never accept `owner_id` / // `organization_id` / audit columns from a visitor, not even via an explicit // section declaration (the insert-forge of #3004, but with no credentials at -// all). The resolve/lookup routes must agree with the submit boundary so a -// form never collects what the submit refuses. +// all). The resolve route must agree with the submit boundary so a form +// never collects what the submit refuses. // // [#6601 / #6920] And a form that declares NO fields publishes nothing and // accepts nothing — one rule on both planes. #3022 originally pinned the @@ -14,6 +14,7 @@ // block for what replaced it and what was kept. import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { RestServer } from './rest-server'; // [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE @@ -86,14 +87,14 @@ const ticketObject = { }, }; -function buildServer(sections: any[] | undefined) { +function buildServer(sections: any[] | undefined, object: any = ticketObject) { const createData = vi.fn().mockResolvedValue({ object: 'ticket', id: 'rec_1', record: {} }); const protocol: any = { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn(async ({ type }: { type: string }) => { if (type === 'view') return [formView(sections)]; - if (type === 'object') return [ticketObject]; + if (type === 'object') return [object]; return []; }), createData, @@ -107,7 +108,6 @@ function buildServer(sections: any[] | undefined) { createData, resolve: find('GET', '/forms/:slug'), submit: find('POST', '/forms/:slug/submit'), - lookup: find('GET', '/forms/:slug/lookup/:field'), }; } @@ -419,38 +419,88 @@ describe('GET /forms/:slug — the published schema IS the declared field set (# }); }); -describe('GET /forms/:slug/lookup/:field — no picker on managed anchors (#3022)', () => { - it('refuses a publicPicker declared on owner_id (would open anonymous sys_user search)', async () => { - // [#7467] Rebuilt THROUGH the schema. The original fixture was a raw - // object handed straight to the stubbed reader — it never met a parse - // door, which is exactly why nobody noticed that `publicPicker` was not - // declarable and this route was unreachable for every spec-valid form. - // Since #7467 the declaration IS spec-valid (proved here through the real - // `ViewMetadataSchema`), so this pin now says what it always meant to: - // even a form that legally authors a picker on a server-managed anchor - // gets 403 — the anchor refusal is the ROUTE's own boundary, not a - // side effect of the schema refusing the form. - const { ViewMetadataSchema } = await import('@objectstack/spec/ui'); - const authored = { - name: 'ticket.contact_form', - object: 'ticket', - viewKind: 'form', - config: { - type: 'simple', - data: { provider: 'object', object: 'ticket' }, - sections: [{ label: 'Details', fields: [{ field: 'owner_id', publicPicker: { displayFields: ['name'] } }] }], - sharing: { allowAnonymous: true, publicLink: '/forms/test' }, - }, - }; - const parsed = ViewMetadataSchema.safeParse(authored); - expect(parsed.success, `the fixture must be SPEC-VALID for this pin to mean anything: ${JSON.stringify((parsed as any).error?.issues)}`).toBe(true); +describe('[#21180] GET /forms/:slug — lookup, master_detail and user fields are stripped unconditionally', () => { + // Ruling E on #21079 (comment 5933054144): anonymous public forms no longer + // take these three field types. The strip already existed; until this card a + // `publicPicker` block on the field's section entry was the opt-in that kept + // the field. The opt-in is retired, so the strip is unconditional — pinned + // here with a STORED pre-retirement row that still carries the block (the + // reader hands the routes the raw row), because that row is exactly what the + // old condition would have kept. + const crmObject = { + name: 'ticket', + label: 'Ticket', + fields: { + subject: { type: 'text', label: 'Subject' }, + contact_id: { type: 'lookup', reference: 'contact', label: 'Contact' }, + account_id: { type: 'master_detail', reference: 'account', label: 'Account' }, + assignee: { type: 'user', label: 'Assignee' }, + }, + }; - const { lookup } = buildServer([ - { fields: [{ field: 'owner_id', publicPicker: { displayFields: ['name'] } }] }, - ]); + it('all three are left off the anonymous rendering, even with a stored picker block; the text field survives', async () => { + const { resolve } = buildServer([{ + label: 'Details', + fields: [ + 'subject', + { field: 'contact_id', publicPicker: { displayFields: ['name'] } }, + { field: 'account_id', publicPicker: { displayFields: ['name'] } }, + { field: 'assignee', publicPicker: { displayFields: ['name'] } }, + ], + }], crmObject); const res = mockRes(); - await lookup.handler({ params: { slug: 'test', field: 'owner_id' }, query: {} } as any, res); - expect(res.statusCode).toBe(403); - expect(res.body.code).toBe('LOOKUP_NOT_PUBLIC'); + await resolve.handler({ params: { slug: 'test' }, headers: {} } as any, res); + expect(res.statusCode).toBe(200); + const rendered = res.body.form.sections.flatMap((s: any) => + (s.fields ?? []).map((f: any) => (typeof f === 'string' ? f : f.field))); + // The plain text field is the control: the strip is by TYPE, not a blanket drop. + expect(rendered).toEqual(['subject']); + }); +}); + +describe('[#21180] GET /forms/:slug/lookup/:field is gone — it answers what any unregistered path answers', () => { + // The anonymous record-search picker route is deleted, not refused: no + // registered route matches the path, so the adapter's own unmatched-request + // answer is the whole response. Driven through the real `HonoHttpServer` + // (the adapter `os serve` mounts) because "unregistered" is the adapter's + // statement, not a handler's. The control is a sibling path of the same + // shape that never existed. + async function answer(path: string) { + const server = new HonoHttpServer(0); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [formView([{ fields: ['subject', 'owner_id'] }])]; + if (type === 'object') return [ticketObject]; + return []; + }), + findData: vi.fn().mockResolvedValue({ records: [{ id: 'usr_1', name: 'Ada' }] }), + }; + const rest = new RestServer(server as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const res: Response = await server.getRawApp().fetch(new Request(`http://local${path}`)); + return { status: res.status, body: await res.json(), findData: protocol.findData }; + } + + it('no route is registered for the path', () => { + const rest = new RestServer(mockServer() as any, {} as any, { api: { requireAuth: false } } as any); + rest.registerRoutes(); + const forms = rest.getRoutes().filter((r) => r.path.includes('/forms/')).map((r) => `${r.method} ${r.path}`); + expect(forms.some((r) => r.includes('/lookup/'))).toBe(false); + // Anti-vacuity: the two surviving public-form routes are still mounted. + expect(forms.some((r) => r.startsWith('GET ') && r.endsWith('/forms/:slug'))).toBe(true); + expect(forms.some((r) => r.startsWith('POST ') && r.endsWith('/forms/:slug/submit'))).toBe(true); + }); + + it('the old picker path and a never-registered sibling get the same answer, and nothing is searched', async () => { + const picker = await answer('/api/v1/forms/test/lookup/owner_id?q=a'); + const control = await answer('/api/v1/forms/test/never_registered/owner_id?q=a'); + expect(picker.status).toBe(404); + expect(picker.body?.error?.code).toBe('ENDPOINT_NOT_FOUND'); + expect(picker.status).toBe(control.status); + expect(JSON.stringify(picker.body).replace('/lookup/', '/never_registered/')).toBe(JSON.stringify(control.body)); + expect(picker.findData).not.toHaveBeenCalled(); }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 815b8f845bc..cd41bacf201 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -386,8 +386,6 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'anonymous public-form spec resolution — browser form runner, not authenticated SDK surface' }, { route: 'POST /api/v1/forms/:slug/submit', family: 'forms', source: 'route-manager', disposition: 'public', note: 'anonymous public-form submission' }, - { route: 'GET /api/v1/forms/:slug/lookup/:field', family: 'forms', source: 'route-manager', disposition: 'public', - note: 'anonymous scoped lookup picker (publicPicker-gated)' }, // ── analytics (semantic layer) ──────────────────────────────────────────── { route: 'POST /api/v1/analytics/dataset/query', family: 'analytics', source: 'route-manager', disposition: 'sdk', client: 'analytics.queryDataset' }, diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 03d2a7d6e62..f46145599ee 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -66,19 +66,15 @@ * if that table ever stops making them equal, this section is where it is * reported rather than in production. * - * ⛔ What §3 is NOT: a claim that either dialect is SERVED. The public picker's - * pair is asserted equal by both REFUSING — its `where` carries - * `ViewFilterRule` rows, which the ingress declines with `400 INVALID_FILTER` - * before and after this card alike. Equality is the assertion; the verdict on - * either side is the ingress's. + * ⛔ What §3 is NOT: a claim that either dialect is SERVED. Equality is the + * assertion; the verdict on either side is the ingress's. * - * [#16581] The ROUTE no longer builds that literal — it lowers the rule rows to - * the `FilterArray` grammar before dispatch — but the pair stays exactly as - * frozen here, and its CONTROL becomes load-bearing in a second way: it is one - * of the two independent pins that the object dialect is still REFUSED, i.e. - * that #16581 lowered the route rather than loosening the parser. ⛔ Never - * "update" the picker pair to the lowered shape: a frozen BEFORE that is - * rewritten to match the after measures nothing. + * [#21180] The public picker's pair and its refusal CONTROL left with the + * route: ruling E on #21079 deleted the anonymous lookup picker, so there is + * no door left that builds that literal. The property the control also held — + * the ingress still REFUSES an array of `{ field, operator, value }` condition + * objects with `400 INVALID_FILTER` — is a property of the normalizer, and the + * metadata-protocol package's own malformed-filter suite pins it directly. */ import { describe, it, expect, vi } from 'vitest'; @@ -293,22 +289,12 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => { }); }); -describe('[#16337] §1 the three sites rest-server.ts names are canonical, by name', () => { +describe('[#16337] §1 the two sites rest-server.ts names are canonical, by name', () => { it('names them', () => { // Belt to §1's braces: the class-wide assertions above would still pass - // over a file that had lost these literals entirely. + // over a file that had lost these literals entirely. [#21180] There + // were three; the public picker's literal left with its route. expect(REST_SERVER).toContain("orderBy: [{ field: 'created_at', order: 'desc' }],"); - // [#21062] The public picker's literal. Its order key is the picker's - // ONE key — the first display field the caller may query, by the - // security service's queryable answer — no longer `displayFields[0]` - // blindly; the literal it sits in is the same canonical QueryAST. The - // second line names where `key` comes from, so the first cannot be - // satisfied by some other `key` in the file. - expect(REST_SERVER).toContain("orderBy: [{ field: key, order: 'asc' }],"); - expect(REST_SERVER).toContain( - 'const key: string | undefined = queryable ? displayFields.find((f) => queryable.has(f)) : displayFields[0];', - ); - expect(REST_SERVER).toContain("fields: ['id', ...displayFields],"); expect(REST_SERVER).toMatch(/expand: Object\.fromEntries\(/); }); }); @@ -609,24 +595,6 @@ const PAIRS: { site: string; wire: Record; canonical: Record { @@ -644,25 +612,6 @@ describe('[#16337] §3 the rewrite moves nothing — driven through the real nor normalized({ object: 'sys_import_job', limit: 2 }), ]).then(([one, two]) => expect(one).not.toEqual(two)); }); - - it('CONTROL: the picker pair is equal by REFUSING, and the refusal is the ingress\'s', async () => { - // Stated rather than left implicit: this pair's equality is not evidence - // that the picker query is served. Both sides carry `ViewFilterRule` - // rows on the filter slot, which is not a `FilterCondition`. - // - // [#16581] ⭐ And this is now the discriminating control for that card: - // the route lowers those rows before dispatch, so it no longer sends - // this literal — while the literal itself must still be REFUSED. A - // green picker search plus a green line here means "the route lowers"; - // a green picker search with this line flipped would have meant "the - // parser was loosened", the repair the ruling excludes. - // [#16638] Located by NAME, not by index: three import-runner pairs were - // inserted above and a positional reference would silently start - // measuring a different row. - const picker = PAIRS.find((p) => p.site.startsWith('public reference picker'))!; - const outcome = await normalized(picker.canonical) as { refused?: { code?: string; status?: number } }; - expect(outcome.refused).toEqual({ code: 'INVALID_FILTER', status: 400 }); - }); }); // --------------------------------------------------------------------------- diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index 778c2d8b7e5..62922865e3b 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -172,13 +172,6 @@ const LEDGER: readonly LedgerRow[] = [ reason: 'A counter column of a persisted `sys_import_job` row, read back from the store and ' + 'mapped to the ImportJobProgress DTO — no request value reaches it.', })), - { - site: 'GET ${basePath}/forms/:slug/lookup/:field » Number(picker.maxResults)', - disposition: 'not-a-query-value', - reason: '`picker` is `fieldCfg.publicPicker`, the STORED form metadata\'s picker config — ' - + 'the author\'s declared result cap, not anything on the request. (The request\'s own ' - + '`?q=` is read as a string two lines below.)', - }, { site: 'POST ${metaPath}/:type/:name/rollback » Number(toVersionRaw)', disposition: 'exempt', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c673773e469..b52e360ec3c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -201,7 +201,6 @@ import { isApiOperationAllowed, API_PRIMITIVES, DATA_ACTION_TO_API_OPERATION, - referenceTargetOf, } from '@objectstack/spec/data'; // [#8013] The SHARED envelope writer (#3973), aliased. [#9098] The alias no // longer exists to dodge a NAME collision — the local responder this used to @@ -346,8 +345,6 @@ import { type ExportFieldMeta, } from './export-format.js'; import { runImport } from '@objectstack/core'; -// [#16581] The public picker's authoring-dialect → parser-grammar lowering. -import { lowerViewFilterRules } from './view-filter-rule-lowering.js'; import { prepareImportRequest } from './import-prepare.js'; // [#17551] The `POST …/analytics/dataset/query` door parse — the half of the // analytics family this route never had. See the module header for the @@ -10697,18 +10694,17 @@ export class RestServer { } catch (e: any) { logError('[REST] Public form schema load failed:', e); } - // Anonymous public forms must NEVER include a lookup or - // master-detail field unless the form designer has - // explicitly opted-in via `publicPicker` on that field's - // section entry (mirroring Airtable's "Allow linking to - // existing records" toggle). Strip non-conforming - // lookups defensively here so a stray spec mistake can - // never expose unrestricted record search to the - // internet — the related `/forms/:slug/lookup/:field` - // endpoint also re-validates `publicPicker` server-side. + // Anonymous public forms NEVER include a lookup, master-detail + // or user field. [#21180] This used to be an opt-in — a + // per-field picker block on the section entry kept the field + // and opened an anonymous record-search route for it. Ruling + // E on #21079 (comment 5933054144) retired the picker and + // deleted that route, so the strip below is now + // unconditional: no declaration can put record search on the + // internet through a public form. const safeForm = (() => { if (!match.form || !Array.isArray(match.form.sections)) return match.form; - const allow = (name: string, cfg: any): boolean => { + const allow = (name: string): boolean => { // [#3022] A declared server-managed anchor (e.g. a // FormView listing `owner_id`) is a spec mistake — // drop it from the rendered sections so the form @@ -10718,17 +10714,14 @@ export class RestServer { const t = def?.type; // `user` is a lookup specialized to sys_user — same risk as a // raw lookup: surfacing it on an anonymous public form would - // expose unrestricted user search to the internet. Gate it - // behind the same `publicPicker` opt-in. - if (t !== 'lookup' && t !== 'master_detail' && t !== 'user') return true; - return !!cfg?.publicPicker; + // expose unrestricted user search to the internet. + return t !== 'lookup' && t !== 'master_detail' && t !== 'user'; }; const sections = match.form.sections.map((sec: any) => { const fields = (sec?.fields ?? []).filter((f: any) => { const name = typeof f === 'string' ? f : f?.field; if (!name) return false; - const cfg = typeof f === 'string' ? {} : f; - return allow(name, cfg); + return allow(name); }); return { ...sec, fields }; }); @@ -10889,369 +10882,6 @@ export class RestServer { tags: ['forms', 'public'], }, }); - - // GET /forms/:slug/lookup/:field — scoped picker for public-form - // lookup widgets. Mirrors Airtable's per-form linked-record search: - // the field MUST be declared in the form spec with an explicit - // `publicPicker` block; otherwise the request is rejected with 403. - // Records are projected to `publicPicker.displayFields`, capped at - // `publicPicker.maxResults` (hard ceiling 50), and pre-filtered by - // `publicPicker.filter`. Anonymous visitors can search but cannot - // enumerate / paginate, so a leaked endpoint cannot exfiltrate the - // table. - this.routeManager.register({ - method: 'GET', - path: `${basePath}/forms/:slug/lookup/:field`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const slug = String(req.params?.slug ?? '').trim(); - const fieldName = String(req.params?.field ?? '').trim(); - if (!slug || !fieldName) { - res.status(400).json({ code: 'INVALID_REQUEST', error: 'slug and field are required' }); - return; - } - const match = await resolveFormBySlug(environmentId, req, slug); - if (!match) { - res.status(404).json({ - code: 'FORM_NOT_FOUND', - error: `No public form configured at /forms/${slug}`, - }); - return; - } - - // Locate the field config and require an opt-in - // `publicPicker` block. Without it the lookup is - // considered private — return 403, not 404, so a - // misconfigured form is loud rather than silent. - // [#3022] Server-managed anchors are unwritable on this - // surface (the submit route strips them), so a picker on - // one (e.g. a declared `owner_id` + `publicPicker`, which - // would open anonymous sys_user search) is refused outright. - let fieldCfg: any = null; - if (!PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(fieldName)) { - for (const sec of match.form?.sections ?? []) { - for (const f of sec?.fields ?? []) { - const name = typeof f === 'string' ? f : f?.field; - if (name === fieldName) { - fieldCfg = typeof f === 'string' ? {} : f; - break; - } - } - if (fieldCfg) break; - } - } - const picker = fieldCfg?.publicPicker; - if (!picker) { - res.status(403).json({ - code: 'LOOKUP_NOT_PUBLIC', - error: `Field "${fieldName}" is not enabled for public lookup on this form`, - }); - return; - } - - // Resolve the referenced object — prefer the explicit - // `publicPicker.object` override, fall back to the - // field def on the parent object. - const p = await this.resolveProtocol(environmentId, req); - let referenceObject: string | undefined = picker.object; - if (!referenceObject && typeof (p as any).getMetaItems === 'function') { - // [#18550] The field def is HOISTED out of the fetch's - // swallow and the carrier is read after it, deliberately. - // The `catch` below exists for the metadata fetch — a - // protocol that cannot answer leaves `referenceObject` - // unset and the route answers `500 LOOKUP_TARGET_MISSING` - // — and an unreadable carrier read INSIDE it would be - // swallowed by it and land on that same envelope, which - // is the conflation this card exists to end: "no target - // is declared" and "the declared target cannot be read" - // want different fixes from whoever owns the metadata. - let fieldDef: unknown; - try { - const objectsRequest: TransportScopedMetaRequest = { - type: 'object', - ...(environmentId ? { environmentId } : {}), - }; - const r: any = await p.getMetaItems(objectsRequest); - const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; - const obj = items.find((o: any) => o?.name === match.object); - // [#7486] Resolve the target from the canonical key — and, since - // [#12920], from it ALONE. `reference` is the spelling `FieldSchema` - // accepts, so it is the only spelling a field def can legitimately - // carry. - // - // ⛔ [#12920] This read used to be a four-spelling tolerant chain - // (`reference ?? referenceTo ?? target ?? options.objectName`). It was - // RETIRED by ruling — director seat summon #20, decision batch #107 - // item 5, 2026-09-09, maintainer verbatim 「其他同意」 = option A — - // executing the stance recorded 2026-08-30, verbatim 「折叠即契约」: - // the spec spelling IS the contract, and a stored row spelling the - // target the old way is a PRODUCER defect, not a shape this route - // accommodates. The prerequisite that had held execution — whether any - // live deployment holds alias-spelled rows — was answered by the - // maintainer: none to preserve. - // - // Wire-visible consequence, deliberate: a stored def spelling the - // target `referenceTo` / `target` / `options.objectName` now resolves - // NOTHING here, and the route answers `500 LOOKUP_TARGET_MISSING` - // instead of searching the aliased object. Pinned, in both directions, - // in `public-form-lookup-picker.test.ts`. - // - // ⛔ Do not re-widen this read, here or in any sibling consumer — - // widening it back is how the platform came to answer the same - // question differently per consumer. Nothing upstream folds for you: - // [#13137] `data/field.zod.ts`'s `aliases` table is a RENAME HINT ON A - // REJECTED KEY, not a normaliser (`strictObject` consults it solely - // from the `unrecognized_keys` path — the semantics are stated in - // `spec/src/shared/strict-object.ts`), so `relatedTo` / `referenceTo` / - // `target` / `targetObject` / `lookupObject` are REFUSED by - // `FieldSchema`, answered with *"Did you mean `referenceTo` → - // `reference`?"*, and never rewritten. The one place an alias IS - // tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`), - // replayed on stored-row rehydration — declared, tested and removable - // on a schedule, which a `??` arm here never was. - // - // [#18550] The canonical-key read itself now happens just BELOW this - // `catch`, through the one arbiter — see there for why it moved. - fieldDef = obj?.fields?.[fieldName]; - } catch {/* ignore */} - // [#19289] The arbiter is `referenceTargetOf`, ⛔ not - // `referenceCarrierOf`. This read has NO type gate — it - // resolves whatever field the picker names — so a - // `{ type: 'user' }` field reaches it, and for that type - // the carrier is not the target: - // `IMPLICIT_REFERENCE_TARGETS` declares it a CONSTANT OF - // THE TYPE (`sys_user`) and metadata authored without - // `reference` "fully specified, not under-specified". - // Reading the carrier answered a spec-complete field - // `500 LOOKUP_TARGET_MISSING`, so opening the picker on - // a "responsible person" column returned an error page. - // ⛔ This is NOT a re-widening of the #12920 narrowing - // below: no alias is re-admitted and no `??` chain - // returns. `referenceTargetOf` reads the canonical key - // through `referenceCarrierOf` and supplies the type's - // own constant only where the spec declares one — a - // stored def spelling the target `referenceTo` / - // `target` / `options.objectName` still resolves NOTHING - // here and still answers `500`. - // - // ABSENCE stays silent and unchanged for the types that - // have no constant: a `lookup` / `master_detail` with - // `undefined` / `null` / `''` still answers `undefined`, - // so the route falls to the `LOOKUP_TARGET_MISSING` - // refusal below exactly as before. UNREADABILITY throws - // past this handler's outer `catch`, which classifies and - // LOGS it (`mapDataError` + `logError`) rather than - // reporting a missing target — and it also stops an - // object-valued carrier from being forwarded as - // `query.object` into `findData`. - referenceObject = referenceTargetOf(fieldDef); - } - if (!referenceObject) { - res.status(500).json({ - code: 'LOOKUP_TARGET_MISSING', - error: `Could not resolve referenced object for "${fieldName}"`, - }); - return; - } - - const displayFields: string[] = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 - ? picker.displayFields.slice(0, 5) - : ['name']; - const hardCap = 50; - const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap); - // [#6877] Same `String(array)` join as `/search`: the - // picker searched for `'a,b'` and showed an empty list. - if (refuseRepeatedQueryParams(req, res, ['q'])) return; - const q = String(req.query?.q ?? '').trim().slice(0, 100); - - const context: any = { - permissions: ['guest_portal'], - anonymous: true, - }; - - // [#21062] The picker's ONE key — the field the search - // predicate below matches and the order further down sorts - // by — is the first display field THIS CALLER MAY QUERY ON, - // by the security service's published answer - // (`getQueryableFields`, #20935), not blindly the first - // display field. A field whose masking rule applies to the - // caller is SERVED (projected, its value masked) and is NOT - // queryable: a `contains` probe on it rebuilds the masked - // value row by row, and an order on it ranks rows by the - // value the mask hides, so the engine refuses both with - // `403 PERMISSION_DENIED`. Keyed blindly, a picker whose - // first display field is masked answered that 403 to every - // caller the rule applies to, on every request. - // - // ⛔ No second derivation of masking here: the answer is the - // security service's, computed by the derivation the - // engine's predicate guard refuses from, and this door only - // reads it. Three states: - // - no security service: this deployment has no - // field-level security, nothing is masked, and the first - // display field stays the key; - // - an answer: the key is the first display field in it; - // - a service that cannot give it (the method is absent, - // or answered `undefined`): the contract's fallback - // (`ISecurityService.getQueryableFields`) — every display - // field whose DECLARATION carries a `maskingRule` is - // passed over, whoever the caller is. A declaration that - // cannot be read admits no display field. - // No display field queryable → the engine's own refusal for - // those fields, its words and its envelope, before the engine - // is asked: the picker never searches or sorts on a field the - // caller may not query. - const security = await this.resolveSecurityService(environmentId, req); - const queryableAnswer = async (): Promise => { - const answer = typeof security?.getQueryableFields === 'function' - ? await security.getQueryableFields(referenceObject, context) - : undefined; - if (answer !== undefined) return answer; - const declared: any = typeof (p as any).getMetaItem === 'function' - ? (await (p as any).getMetaItem({ - type: 'object', - name: referenceObject, - ...(environmentId ? { environmentId } : {}), - }))?.item?.fields - : undefined; - if (!declared || typeof declared !== 'object') return []; - const declarationOf = (f: string): any => (Array.isArray(declared) - ? declared.find((d: any) => d?.name === f) - : declared[f]); - return displayFields.filter((f) => declarationOf(f)?.maskingRule == null); - }; - const queryable: ReadonlySet | undefined = security - ? new Set(await queryableAnswer()) - : undefined; - const key: string | undefined = queryable ? displayFields.find((f) => queryable.has(f)) : displayFields[0]; - if (key === undefined) { - const refused = displayFields.filter((f) => !queryable?.has(f)); - const denied: any = new Error( - `[Security] Access denied: query on '${referenceObject}' references field(s) not readable by the caller: ` - + `${refused.join(', ')}. Filtering, sorting, grouping, or aggregating by a hidden field ` - + `would leak its values (filter oracle) — remove these predicates or grant field read access.`, - ); - denied.name = 'PermissionDeniedError'; - denied.code = 'PERMISSION_DENIED'; - denied.statusCode = 403; - const mapped = mapDataError(denied); - res.status(mapped.status).json(mapped.body); - return; - } - - // Compose filters: form-defined static filter first, - // then the search predicate on the key above. The - // search predicate uses `contains` so non-indexed - // columns still work. - // - // [#16581] …and then LOWER the composed rows to the filter - // grammar the ingress parses. BOTH halves are the authoring - // dialect `FormFieldPublicPickerSchema.filter` declares - // (`{field, operator, value}`) — the declared rows because - // an author wrote them, the search row because this route - // built it in the same shape — and the normalizer refuses - // that shape with `400 INVALID_FILTER`. So the endpoint - // answered 400 for EVERY non-empty search, with or without a - // declared `publicPicker.filter`; only the degenerate - // no-filter call could succeed. `lowerViewFilterRules` is - // the one-way translation (authoring dialect → - // `FilterArray`) and lives at this door because this is the - // door that speaks both; ⛔ the repair the ruling excludes - // is teaching `findData` a second dialect. - const rules: any[] = []; - if (Array.isArray(picker.filter)) rules.push(...picker.filter); - if (q) rules.push({ field: key, operator: 'contains', value: q }); - const filters = lowerViewFilterRules(rules); - - const pickerRequest: ServerScopedDataRequest = { - object: referenceObject, - // [#16337] Canonical QueryAST: `filters` → `where`, - // `select` → `fields`, `sort` → `orderBy`. The normalizer - // folds each of those aliases onto exactly these keys and - // moves the value verbatim, so this is a spelling change - // and nothing else. - // - // ⚠️ The VALUE on `where` is a `FilterArray`, not a - // `FilterCondition`. #16337 left `ViewFilterRule` OBJECTS - // here — the dialect `FormFieldPublicPickerSchema.filter` - // declares — which the ingress refuses with - // `400 INVALID_FILTER`; #16581 lowers them above, so what - // arrives is the declared array grammar the normalizer - // parses. `FilterCondition`'s `[key: string]: any` index - // signature is why an array compiles against the slot at - // all; that the value is now a filter the ingress ACCEPTS - // is measured end-to-end, not asserted by the type. - query: { - object: referenceObject, - limit: maxResults, - offset: 0, - where: filters, - fields: ['id', ...displayFields], - // [#7485] Ordering is FIXED — the key above (the - // first display field the caller may query on, - // #21062), ascending. This used to read `picker.sort`, a key - // `FormFieldPublicPickerSchema` (#7467) deliberately - // never declared: enforced by the route, authorable - // nowhere. The maintainer ruled retire-the-read over - // declare-the-key — zero measured pull for a - // permanently-maintained public key on an - // UNAUTHENTICATED surface. A pre-schema stored row - // still carrying `sort` is IGNORED, not an error. - orderBy: [{ field: key, order: 'asc' }], - }, - ...(environmentId ? { environmentId } : {}), - context, - }; - const result: any = await p.findData(pickerRequest); - - // Project the response server-side too — never trust - // that the driver respected `select`. - // - // [#16581] `records` FIRST, which is the key `findData` - // actually returns (`{ object, records, total, hasMore }`) - // and the order the other three read sites in this file - // already use. This one read `data` / `items` and NOT - // `records`, so against the real protocol it matched - // nothing and the picker answered `200 {"data":[]}` — an - // empty list for every search. Invisible until the filter - // above stopped 400ing, and invisible to the sibling suite - // because its `findData` double answers `{ data }`, a shape - // the protocol does not produce. The legacy aliases stay so - // those doubles and alternate protocols keep working. - const rows: any[] = Array.isArray(result?.records) ? result.records - : Array.isArray(result?.data) ? result.data - : Array.isArray(result?.items) ? result.items - : Array.isArray(result?.rows) ? result.rows - : Array.isArray(result) ? result : []; - const projected = rows.slice(0, maxResults).map((row: any) => { - const out: any = { id: row?.id }; - for (const f of displayFields) { - if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f]; - } - return out; - }); - res.json({ - data: projected, - total: projected.length, - truncated: rows.length >= maxResults, - displayFields, - }); - } catch (error: any) { - const mapped = mapDataError(error); - // Distinct message (this is not the "unhandled" channel), - // same shared verdict — see `isExpectedRouteError`. - if (!isExpectedRouteError(mapped.status, mapped.body)) { - logError('[REST] Public form lookup error:', error); - } - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Scoped lookup picker for a public form field (anonymous)', - tags: ['forms', 'public'], - }, - }); } /** diff --git a/packages/rest/src/view-filter-rule-lowering.ts b/packages/rest/src/view-filter-rule-lowering.ts deleted file mode 100644 index 634cd23e3f9..00000000000 --- a/packages/rest/src/view-filter-rule-lowering.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16581] Lower `ViewFilterRule` rows to the filter grammar the data ingress - * actually parses. - * - * ## The gap this closes - * - * `FormFieldPublicPickerSchema.filter` declares the object dialect in so many - * words — *"Same `{ field, operator, value }` dialect as list-view filters"* — - * and `GET /forms/:slug/lookup/:field` put those rows straight onto the - * `findData` filter slot. That slot is read by - * `@objectstack/metadata-protocol`'s normalizer, which accepts a - * `FilterCondition` object or a `FilterArray` (`[field, operator, value]`, a - * logical node, or a list of those) and refuses anything else with - * `400 INVALID_FILTER`. An array of `{field, operator, value}` OBJECTS is none - * of those, so the route answered 400 for **every** non-empty search — the - * declared pre-filter and the route's own `q` predicate alike, since the `q` - * branch builds the same object shape. Only the degenerate empty-filter call - * could succeed. - * - * ⛔ The repair is NOT a second dialect on `findData`. Two filter grammars in - * the data layer would be maintained forever and would spread the object shape - * to every `findData` caller; the declaring side already promises the object - * dialect on the AUTHORING surface, so what has to change is the side that - * failed to honour it. This module is that side: authoring dialect in, - * parser grammar out, at the one door that speaks both. - * - * ## The operator fold is the spec's own, not a second table - * - * {@link normalizeFilterOperator} (`@objectstack/spec/ui`) is the fold - * `ViewFilterRuleSchema.operator` itself runs as its `z.preprocess`, exported - * precisely so "producers and renderers can normalize stored metadata against - * the SAME canonical map the schema uses, instead of inventing a second - * dialect". ⛔ Never hand-write an alias table here: a stored row predating a - * spelling's canonicalisation (`notEquals`, `isNotEmpty`, `gt`) must fold the - * way the schema folds it, and `AST_OPERATOR_MAP`'s coverage of that vocabulary - * is what `filter-view-operator-parity.test.ts` holds. - * - * ## An unlowerable row is FORWARDED, never dropped - * - * A row this function cannot read as a rule passes through verbatim, so the - * ingress refuses the whole request exactly as it did before. That direction is - * deliberate and it is the fail-CLOSED one: a picker's static filter is often - * the only thing keeping an anonymous visitor's search inside the rows a form - * is allowed to expose (`filter: [{ field: 'status', … 'published' }]`). - * Skipping a row we did not understand would turn a loud 400 into a 200 over an - * UNFILTERED table on an unauthenticated surface — a widening, delivered - * silently, by the code that was supposed to be repairing a refusal. - */ - -import { normalizeFilterOperator } from '@objectstack/spec/ui'; - -/** - * One rule → one `FilterArray` comparison node, or the input verbatim when it - * is not a readable `{ field, operator, value }` row (see the module header: - * that is the fail-closed path, not a fallback). - * - * `value: undefined` emits the two-element form the grammar declares - * (`[field, operator]`) rather than a triple with an `undefined` in comparand - * position. That is the shape a unary rule authors as — `ViewFilterRuleSchema` - * documents `is_empty` / `is_not_empty` / `is_null` / `is_not_null` as taking - * their direction from the operator NAME and ignoring `value` — and it needs no - * local list of which operators are unary, which would be a third copy of a - * vocabulary the spec already owns. - */ -function lowerViewFilterRule(rule: unknown): unknown { - if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return rule; - const { field, operator, value } = rule as { field?: unknown; operator?: unknown; value?: unknown }; - if (typeof field !== 'string' || field.length === 0) return rule; - if (typeof operator !== 'string') return rule; - const op = normalizeFilterOperator(operator); - return value === undefined ? [field, op] : [field, op, value]; -} - -/** - * Lower a list of `ViewFilterRule` rows to the value the filter slot takes. - * - * - no rows → `[]`, which every path already reads as "no filter". ⛔ Not - * `['and']`: a logical node with nothing to join is itself refused (the one - * shape that used to return every row silently), and "the author declared no - * pre-filter" must not become a rejected request. - * - one or more rows → an explicit `['and', …]` node. The route ANDs its - * static rows with the visitor's search predicate, so the conjunction is - * written down rather than left to the list form's implicit AND. - */ -export function lowerViewFilterRules(rules: readonly unknown[]): unknown[] { - if (rules.length === 0) return []; - return ['and', ...rules.map(lowerViewFilterRule)]; -} diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index d6cf52aedb7..8ef422da524 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -323,8 +323,8 @@ "status": "live", "verifiedAt": "2026-08-11", "evidenceScope": "cross-repo", - "evidence": "objectui: form renderers + spec-bridge form-view.ts:169 (audit L17); packages/rest/src/rest-server.ts registerPublicFormEndpoints (the three /forms/:slug routes walk sections: resolve strips undeclared lookup/master_detail/user fields, submit builds allowedFields from them, and GET /forms/:slug/lookup/:field reads each field entry's publicPicker block)", - "note": "This row carries the blanket verdict for the whole FormField subtree beneath it (undrilled inheritance). [#7467] adds `publicPicker` {displayFields,maxResults,filter,object} to that subtree — live, measured reader: the GET /forms/:slug/lookup/:field handler in packages/rest/src/rest-server.ts (displayFields projection + contains search, maxResults clamp to hard ceiling 50, filter rows composed ahead of the search predicate, object override for the referenced object). The key had been ENFORCED there but declared nowhere — ADR-0049's mirror direction (enforced, never declarable) — until the maintainer ruled declare (option 1). The route also reads picker.sort, which stays deliberately UNDECLARED (outside the ruling's four-key enumeration; follow-up finding filed from #7467); a form authoring it is a loud parse error, pinned in view-public-picker.test.ts. [#12174] drills the six FormField constraint keys out of the blanket verdict — maxLength/minLength/min/max/precision/scale are all LIVE, measured at objectui@f7c52e2 (2026-08-26): two independent spec-to-runtime routes copy all six onto the runtime field (packages/react/src/spec-bridge/bridges/form-view.ts mapField, objectui#5898; packages/plugin-form/src/sectionFields.ts normalizeSectionField), the console FormPage merges override.maxLength ?? def.maxLength onto the rendered input (apps/console/src/components/FormPage.tsx:548, objectui#5595), packages/fields buildValidationRules turns minLength/maxLength/min/max into react-hook-form rules, ObjectForm.tsx:691 derives the number input step from precision, and NumberField.tsx:21 reads scale; server-side, GET /forms/:slug (packages/rest/src/rest-server.ts) serves the rows verbatim to anonymous renderers (a conduit, no key-level read). On that measurement the four count-shaped keys tightened to the #11566/#11949/#8321 value shapes (lengths int>=1, precision/scale int>=0; min/max stay bare numbers — they are values, not counts)." + "evidence": "objectui: form renderers + spec-bridge form-view.ts:169 (audit L17); packages/rest/src/rest-server.ts registerPublicFormEndpoints (the two /forms/:slug routes walk sections: resolve strips every lookup/master_detail/user field from the anonymous rendering, submit builds allowedFields from them)", + "note": "This row carries the blanket verdict for the whole FormField subtree beneath it (undrilled inheritance). [#7467] had added the anonymous public-form picker block to that subtree, read only by the anonymous lookup route; [#21180] REMOVED it 2026-10-01 (ADR-0087 D2, ruling E on #21079): the key is now a retiredKey() tombstone in FormFieldBaseSchema carrying the prescription, the route is deleted, and the resolve route strips lookup/master_detail/user fields from the anonymous rendering unconditionally. The subtree has no per-key rows, so the tombstone adds none; the retirement is registered as ui/FormField:publicPicker in RETIRED_KEYS_BY_MAJOR[18]. [#12174] drills the six FormField constraint keys out of the blanket verdict — maxLength/minLength/min/max/precision/scale are all LIVE, measured at objectui@f7c52e2 (2026-08-26): two independent spec-to-runtime routes copy all six onto the runtime field (packages/react/src/spec-bridge/bridges/form-view.ts mapField, objectui#5898; packages/plugin-form/src/sectionFields.ts normalizeSectionField), the console FormPage merges override.maxLength ?? def.maxLength onto the rendered input (apps/console/src/components/FormPage.tsx:548, objectui#5595), packages/fields buildValidationRules turns minLength/maxLength/min/max into react-hook-form rules, ObjectForm.tsx:691 derives the number input step from precision, and NumberField.tsx:21 reads scale; server-side, GET /forms/:slug (packages/rest/src/rest-server.ts) serves the rows verbatim to anonymous renderers (a conduit, no key-level read). On that measurement the four count-shaped keys tightened to the #11566/#11949/#8321 value shapes (lengths int>=1, precision/scale int>=0; min/max stay bare numbers — they are values, not counts)." }, "groups": { "status": "live", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 229345964e1..778329333ce 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -251,8 +251,6 @@ export const ERROR_CODE_LEDGER = { 'INTERNAL', 'INVALID_REQUEST', 'INVALID_STATE', - 'LOOKUP_NOT_PUBLIC', - 'LOOKUP_TARGET_MISSING', 'MAPPING_FORMAT_MISMATCH', 'MAPPING_FORMAT_UNSUPPORTED', 'MAPPING_NOT_FOUND', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index cfd5daf47c3..5bbf5efb032 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -12714,6 +12714,143 @@ const reportJoinedChartRemoved: MetadataConversion = { }, }; +/** + * [#21180] The form field's `publicPicker` block leaves the FormField + * vocabulary (protocol 18, ADR-0087 D2 — the maintainer's ruling E on #21079, + * comment 5933054144, which reverses the #7467 ruling that had declared it). + * + * The block opted a lookup / `master_detail` / `user` field on an ANONYMOUS + * public form into a record-search picker served by an unauthenticated route. + * The ruling retired the capability: anonymous public forms no longer take + * those three field types, the route (`GET /forms/:slug/lookup/:field`) is + * deleted, and the public-form resolve route now leaves them off the anonymous + * rendering unconditionally. The schema tombstones the key with the + * prescription (`FORM_FIELD_PUBLIC_PICKER_RETIRED`, `ui/view.zod.ts`); this + * entry strips it from stored sources. + * + * A pure delete, and lossless in effect: the only reader of the block was the + * deleted route, and the resolve route no longer consults it — the field is + * left off the anonymous rendering whether or not a stored row still carries + * the key. There is no conversion TO anything: an anonymous form that needs a + * choice uses a `select` field with static `options`, and one that needs an + * existing record goes behind sign-in — a judgement the semantic entry + * `form-field-public-picker-retired` asks the upgrader to make. + * + * Walks the same payloads as `form-view-option-default-removed` — every FORM + * payload {@link mapViewPayloads} reaches, in all three persisted spellings — + * through `sections[]` / `groups[]` and top-level `fields[]`, recursing into + * nested `fields`. Only the exact key `publicPicker` is stripped; a + * string-shorthand field entry carries no keys and rides through untouched. + */ +const formFieldPublicPickerRemoved: MetadataConversion = { + id: 'form-field-public-picker-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: 'view.form.sections[].fields[].publicPicker', + summary: + "form field 'publicPicker' removed (ADR-0087 D2 — the anonymous public-form record-search " + + 'picker is retired: an anonymous public form no longer takes lookup, master_detail or user ' + + 'fields, and the anonymous lookup route is gone. Use a select field with static options, or ' + + 'put the form behind sign-in)', + apply(stack, emit) { + const mapFields = (fields: unknown, path: string): unknown => { + if (!Array.isArray(fields)) return fields; + let changed = false; + const next = fields.map((field, i) => { + if (!isDict(field)) return field; + let dict: Dict = stripKeys(field, ['publicPicker'], emit, `${path}[${i}]`); + const nested = mapFields(dict.fields, `${path}[${i}].fields`); + if (nested !== dict.fields) dict = { ...dict, fields: nested }; + if (dict !== field) changed = true; + return dict; + }); + return changed ? next : fields; + }; + const mapSections = (sections: unknown, path: string): unknown => { + if (!Array.isArray(sections)) return sections; + let changed = false; + const next = sections.map((section, i) => { + if (!isDict(section)) return section; + let dict: Dict = section; + const fields = mapFields(dict.fields, `${path}[${i}].fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + if (dict !== section) changed = true; + return dict; + }); + return changed ? next : sections; + }; + const mapForm = (form: unknown, path: string): unknown => { + if (!isDict(form)) return form; + let dict: Dict = form; + for (const key of ['sections', 'groups'] as const) { + const mapped = mapSections(dict[key], `${path}.${key}`); + if (mapped !== dict[key]) dict = { ...dict, [key]: mapped }; + } + const fields = mapFields(dict.fields, `${path}.fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + return dict; + }; + return mapViewPayloads(stack, (payload, kind, path) => + kind === 'form' ? (mapForm(payload, path) as Dict) : payload); + }, + fixture: { + before: { + views: [{ + object: 'crm_inquiry', + formViews: { + contact: { + sections: [{ + label: 'About you', + fields: [ + // A string-shorthand entry carries no keys and rides through. + 'subject', + // The measured authored shape: a lookup field opted into the + // anonymous picker. The block goes whole, whatever it held. + { + field: 'account', + publicPicker: { displayFields: ['name'], maxResults: 10, object: 'crm_account' }, + }, + // A nested row — the strip recurses through `fields`. + { + field: 'details', + type: 'composite', + fields: [{ field: 'owner', publicPicker: { displayFields: ['name'] } }], + }, + ], + }], + sharing: { allowAnonymous: true, publicLink: '/forms/contact' }, + }, + }, + }], + }, + after: { + views: [{ + object: 'crm_inquiry', + formViews: { + contact: { + sections: [{ + label: 'About you', + fields: [ + 'subject', + { field: 'account' }, + { + field: 'details', + type: 'composite', + fields: [{ field: 'owner' }], + }, + ], + }], + sharing: { allowAnonymous: true, publicLink: '/forms/contact' }, + }, + }, + }], + }, + // One per stripped field entry — the top-level row's block and the nested one's. + expectedNotices: 2, + }, +}; + /** * Form `layout` sheds its `inline` and `grid` arms (protocol 18, #20221 — * ADR-0049 enforce-or-remove; triage direction under the maintainer's #18900 @@ -13465,6 +13602,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: fieldMalformedScalePrecisionRemoved, order: 1 }, { conversion: fieldReferenceToAlias, order: 18 }, { conversion: flowDecisionModeInclusiveExplicit, order: 45 }, + { conversion: formFieldPublicPickerRemoved, order: 53 }, { conversion: formLayoutInlineGridToVertical, order: 40 }, { conversion: formViewOptionDefaultRemoved, order: 17 }, { conversion: formViewSubformColumnsCanonicalized, order: 51 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__publicPicker.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__publicPicker.ts new file mode 100644 index 00000000000..1ad65cf1485 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormField__publicPicker.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21180 — ADR-0087 D2, immediate retirement, by the maintainer's ruling E on +// #21079 (comment 5933054144), which reverses the #7467 ruling that had +// declared the key. The block opted a lookup / `master_detail` / `user` field +// on an ANONYMOUS public form into a record-search picker served by an +// unauthenticated route (`GET /forms/:slug/lookup/:field`). The ruling retired +// the capability: the route is deleted, and the public-form resolve route now +// leaves those three field types off the anonymous rendering unconditionally. +// Zero producers measured before the ruling — no example, template, plugin or +// first-party UI caller declared one. +// +// `retiredKey()` on the form field's shape, for the prescription. Sources are +// rewritten by the D2 conversion `form-field-public-picker-removed`; the D3 +// record is `form-field-public-picker-retired`. +// +// Registered under 18, not 17: the tombstone ships on the 17.x line +// (launch-window convention — accept-set narrowings ride minor releases) and +// the prescription lives at the major boundary where `migrate meta` users look. +export const entry = 'ui/FormField:publicPicker'; diff --git a/packages/spec/src/migrations/entries/semantic/18.form-field-public-picker-retired.ts b/packages/spec/src/migrations/entries/semantic/18.form-field-public-picker-retired.ts new file mode 100644 index 00000000000..d683fd1c879 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.form-field-public-picker-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21180 — ADR-0087 D2, immediate retirement (the maintainer's ruling E on +// #21079, comment 5933054144, reversing the #7467 ruling) — the D3 entry of the +// `form-field-public-picker-removed` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). Registered key: +// `ui/FormField:publicPicker`. The strip changes nothing a visitor sees — the +// resolve route already leaves the field off the anonymous rendering — but the +// visitor's way to choose a value is gone, and only the author can say what +// replaces it. +export const entry: SemanticMigration = { + id: 'form-field-public-picker-retired', + surface: 'view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker', + replacement: 'No record search on an anonymous public form. For a choice from a fixed list, a ' + + '`select` field with static `options`. For a choice of an existing record, the same form ' + + 'behind sign-in, where the lookup field renders with the signed-in user\'s access.', + reason: 'The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every ' + + 'form field, and the delete is lossless in effect: the block\'s only reader was the ' + + 'anonymous lookup route, which is gone, and the public-form resolve route now leaves ' + + 'lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row ' + + 'carries. What the strip cannot decide is the visitor\'s path. A public form that used the ' + + 'picker let an anonymous visitor search and pick a record; after the upgrade that field is ' + + 'simply absent from the form, so a submission arrives without the value. Whether the ' + + 'choice was really from a small fixed set (a `select` with static `options`), or needs a ' + + 'real record and therefore a signed-in user, is a product decision only the author can make.', + acceptanceCriteria: 'No form field carries `publicPicker`; the parse refuses it. Every public ' + + 'form that had carried one either replaces the lookup field with a `select` field whose ' + + 'static `options` list the allowed choices, or is served behind sign-in, or the author has ' + + 'confirmed the form works without the value. Fetching the public form anonymously ' + + '(`GET /forms/:slug`) shows no lookup, `master_detail` or `user` field in its sections.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index da94c487ba6..a04a9b52b02 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5502,6 +5502,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'it; `os migrate meta --stored` lists each one for review, and `mode: \'inclusive\'` is ' + 'the one-line fix where a node meant every branch.', }, + { + id: 'form-field-public-picker-retired', + order: 56, + text: + 'Finally, it retires the form field\'s `publicPicker` block (ADR-0087 D2, immediate — the ' + + 'maintainer\'s ruling E, which reverses the earlier ruling that had declared it): an ' + + 'anonymous public form no longer offers record search. The block opted a lookup, ' + + '`master_detail` or `user` field on a public form into a picker served by an ' + + 'unauthenticated route; that route is deleted, and the public-form resolve route now ' + + 'leaves those three field types off the anonymous rendering unconditionally. The schema ' + + 'refuses the key with the prescription; the mechanical conversion ' + + '`form-field-public-picker-removed` strips it from old sources and stored rows (lossless ' + + 'in effect — its only reader was the deleted route), and the semantic entry asks the ' + + 'author how a visitor should now choose: a `select` field with static `options`, or a ' + + 'form behind sign-in.', + }, { id: 'form-view-option-default-retired', order: 19, @@ -12119,6 +12135,35 @@ const step18: MigrationStep = { + 'predicate parses and registers byte-identically to before, and a non-string in these ' + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).', }, + // #21180 — ADR-0087 D2, immediate retirement (the maintainer's ruling E on + // #21079, comment 5933054144, reversing the #7467 ruling) — the D3 entry of the + // `form-field-public-picker-removed` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). Registered key: + // `ui/FormField:publicPicker`. The strip changes nothing a visitor sees — the + // resolve route already leaves the field off the anonymous rendering — but the + // visitor's way to choose a value is gone, and only the author can say what + // replaces it. + { + id: 'form-field-public-picker-retired', + surface: 'view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker', + replacement: 'No record search on an anonymous public form. For a choice from a fixed list, a ' + + '`select` field with static `options`. For a choice of an existing record, the same form ' + + 'behind sign-in, where the lookup field renders with the signed-in user\'s access.', + reason: 'The D2 conversion `form-field-public-picker-removed` deletes `publicPicker` from every ' + + 'form field, and the delete is lossless in effect: the block\'s only reader was the ' + + 'anonymous lookup route, which is gone, and the public-form resolve route now leaves ' + + 'lookup, `master_detail` and `user` fields off the anonymous rendering whatever the row ' + + 'carries. What the strip cannot decide is the visitor\'s path. A public form that used the ' + + 'picker let an anonymous visitor search and pick a record; after the upgrade that field is ' + + 'simply absent from the form, so a submission arrives without the value. Whether the ' + + 'choice was really from a small fixed set (a `select` with static `options`), or needs a ' + + 'real record and therefore a signed-in user, is a product decision only the author can make.', + acceptanceCriteria: 'No form field carries `publicPicker`; the parse refuses it. Every public ' + + 'form that had carried one either replaces the lookup field with a `select` field whose ' + + 'static `options` list the allowed choices, or is served behind sign-in, or the author has ' + + 'confirmed the form works without the value. Fetching the public form anonymously ' + + '(`GET /forms/:slug`) shows no lookup, `master_detail` or `user` field in its sections.', + }, // The D3 entry of the `form-view-option-default-removed` family, which landed // in commit c459da6bc: a maintainer-ruled narrowing on the objectui#6263 // analysis that took per-option `default` out of the form-view vocabulary and @@ -23084,6 +23129,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `element-input-target-variable-removed` (a page component IS a stack // collection member, unlike the `kernel/Manifest:loading` family). 'ui/ElementTextInputProps:targetVariable', + // #21180 — ADR-0087 D2, immediate retirement, by the maintainer's ruling E on + // #21079 (comment 5933054144), which reverses the #7467 ruling that had + // declared the key. The block opted a lookup / `master_detail` / `user` field + // on an ANONYMOUS public form into a record-search picker served by an + // unauthenticated route (`GET /forms/:slug/lookup/:field`). The ruling retired + // the capability: the route is deleted, and the public-form resolve route now + // leaves those three field types off the anonymous rendering unconditionally. + // Zero producers measured before the ruling — no example, template, plugin or + // first-party UI caller declared one. + // + // `retiredKey()` on the form field's shape, for the prescription. Sources are + // rewritten by the D2 conversion `form-field-public-picker-removed`; the D3 + // record is `form-field-public-picker-retired`. + // + // Registered under 18, not 17: the tombstone ships on the 17.x line + // (launch-window convention — accept-set narrowings ride minor releases) and + // the prescription lives at the major boundary where `migrate meta` users look. + 'ui/FormField:publicPicker', // #20161 (ADR-0049 enforce-or-remove). `JoinedReportBlock.chart` declared an // inline chart on one block of a `joined` report, and no renderer ever drew it: // at the `.objectui-sha` pin `f8a9d0fb0596`, `DatasetReportRenderer`'s joined diff --git a/packages/spec/src/ui/form-field-public-picker-retirement.test.ts b/packages/spec/src/ui/form-field-public-picker-retirement.test.ts new file mode 100644 index 00000000000..640b06c6a5c --- /dev/null +++ b/packages/spec/src/ui/form-field-public-picker-retirement.test.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The form field's `publicPicker` RETIRED (#21180) — ADR-0087 D2, immediate + * retirement, by the maintainer's ruling E on #21079 (comment 5933054144), + * which reverses the #7467 ruling that had declared the key. + * + * The block opted a lookup / `master_detail` / `user` field on an ANONYMOUS + * public form into a record-search picker served by an unauthenticated route. + * The ruling retired the capability: anonymous public forms no longer take + * those three field types, `GET /forms/:slug/lookup/:field` is deleted, and the + * resolve route strips them from the anonymous rendering unconditionally (the + * REST half is pinned in `packages/rest/src/public-form-routes.test.ts`). + * + * Measured before removal (the card's readings, re-taken on this branch's + * base): no producer outside spec, tests, docs and the route — no example, + * template, plugin or first-party UI caller — and objectui at its pin imports + * neither the key nor the schema. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `FormFieldBaseSchema` (strict through + * `FormFieldSchema`), so the parse carries the prescription instead of a + * bare unknown-key verdict and `tsc` types the key `never`. + * `FormFieldPublicPickerSchema` and its two types are deleted outright — + * nothing else referenced them. + * 2. D2 conversion `form-field-public-picker-removed` (step 18), a lossless + * delete over every form payload's field entries, retired from the load + * path: a live author is refused, a stored row replays clean. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/FormField:publicPicker`, and the + * family's D3 entry is `form-field-public-picker-retired`. + * 4. No liveness row moves: the key sat in the undrilled `view/form.sections` + * subtree, whose blanket row carries a note recording the removal. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, pinned with its `code` and `status` below. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { FormFieldSchema, FormViewSchema, ViewMetadataSchema, type FormFieldInput } from './view.zod'; + +/** The block as an author wrote it before the retirement — every key it declared. */ +const PICKER = { displayFields: ['name'], maxResults: 10, object: 'crm_contact' }; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`view\.form\.sections\[\]\.fields\[\]\.publicPicker` was removed in @objectstack\/spec 17\.6\.0 \(ADR-0087 D2\).*no longer offers record search.*Delete the key.*`select` field with static `options`.*behind sign-in.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** A ViewItem-branch form carrying the given field entries (the `saveMetaItem` door). */ +const viewItem = (fields: unknown[]) => ({ + name: 'crm_inquiry.contact', + object: 'crm_inquiry', + viewKind: 'form', + label: 'Contact us', + config: { + type: 'simple', + data: { provider: 'object', object: 'crm_inquiry' }, + sections: [{ label: 'About you', fields }], + sharing: { allowAnonymous: true, publicLink: '/forms/contact' }, + }, +}); + +describe('form field publicPicker retirement — the tombstone, at every door that carries a form field', () => { + it('the form field schema refuses `publicPicker` at its path, with the prescription', () => { + const r = FormFieldSchema.safeParse({ field: 'contact', publicPicker: PICKER }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['publicPicker']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`view.form.sections[].fields[].publicPicker` was removed')).toBe(true); + }); + + it('refuses every value shape, the empty block included — the tombstone accepts only absence', () => { + for (const value of [{}, PICKER, true, null, 'yes']) { + const r = FormFieldSchema.safeParse({ field: 'contact', publicPicker: value }); + expect(r.success, `publicPicker: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.path).toEqual(['publicPicker']); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION); + } + }); + + it('the code-authored form door (FormViewSchema) refuses it at sections[N].fields[N].publicPicker', () => { + const r = FormViewSchema.safeParse({ + type: 'simple', + sections: [{ label: 'About you', fields: ['subject', { field: 'contact', publicPicker: PICKER }] }], + }); + expect(r.success).toBe(false); + if (r.success) return; + const flat = JSON.stringify(r.error.issues); + expect(flat).toMatch(PRESCRIPTION); + expect(flat).toContain('"publicPicker"'); + }); + + it('the stored-view write door (ViewMetadataSchema, ViewItem branch) refuses it with the prescription', () => { + const r = ViewMetadataSchema.safeParse(viewItem(['subject', { field: 'contact', publicPicker: PICKER }])); + expect(r.success).toBe(false); + if (r.success) return; + expect(JSON.stringify(r.error.issues)).toMatch(PRESCRIPTION); + // CONTROL: the same item without the key is accepted by the same door. + expect(ViewMetadataSchema.safeParse(viewItem(['subject', { field: 'contact' }])).success).toBe(true); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const stack = (field: Record) => ({ + manifest: { id: 'com.example.public-picker', name: 'public_picker', version: '1.0.0', type: 'app' }, + objects: [{ + name: 'crm_inquiry', + label: 'Inquiry', + fields: { + subject: { type: 'text', label: 'Subject' }, + contact: { type: 'lookup', label: 'Contact', reference: 'crm_contact' }, + }, + }], + views: [{ + formViews: { + contact: { + type: 'simple', + data: { provider: 'object', object: 'crm_inquiry' }, + sections: [{ label: 'About you', fields: ['subject', field] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact' }, + }, + }, + }], + }); + let thrown: unknown; + try { + defineStack(stack({ field: 'contact', publicPicker: PICKER }) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(JSON.stringify(refusal.issues)).toMatch(PRESCRIPTION); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ field: 'contact' }) as never)).not.toThrow(); + }); + + it('CONTROL: the same field without the key parses, and absence stays absence', () => { + const r = FormFieldSchema.safeParse({ field: 'contact', required: true }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).not.toHaveProperty('publicPicker'); + }); + + it('fails tsc at the authoring site: the input type of `publicPicker` is `never`', () => { + const field: FormFieldInput = { + field: 'contact', + // @ts-expect-error — `publicPicker` is a retiredKey() tombstone: its input type is `never`. + publicPicker: PICKER, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => FormFieldSchema.parse(field)).toThrow(PRESCRIPTION); + }); +}); + +describe('form field publicPicker retirement — the D2 conversion', () => { + it('is registered for protocol 18, retired from the load path, stamped with the label it lands on', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'form-field-public-picker-removed'); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.retiredAfter).toBe('17.5.0'); + }); + + it('fires on every form payload spelling and every field position, one notice per stripped entry', () => { + const { stack, notices } = collectConversionNotices( + { + views: [ + // Container: the default `form` slot and a named `formViews` entry, with + // `sections[]`, `groups[]`, top-level `fields[]` and a nested row. + { + object: 'crm_inquiry', + form: { sections: [{ fields: ['subject', { field: 'contact', publicPicker: PICKER }] }] }, + formViews: { + intake: { + groups: [{ fields: [{ field: 'account', publicPicker: {} }] }], + fields: [{ field: 'details', type: 'composite', fields: [{ field: 'owner', publicPicker: PICKER }] }], + }, + }, + }, + // A ViewItem record — the payload hangs off `config`. + viewItem([{ field: 'contact', publicPicker: PICKER }]), + ], + }, + { includeRetired: true }, + ); + const mine = notices.filter((n) => n.conversionId === 'form-field-public-picker-removed'); + expect(mine.map((n) => n.path).sort()).toEqual([ + 'views[0].form.sections[0].fields[1].publicPicker', + 'views[0].formViews.intake.fields[0].fields[0].publicPicker', + 'views[0].formViews.intake.groups[0].fields[0].publicPicker', + 'views[1].config.sections[0].fields[0].publicPicker', + ]); + for (const n of mine) { + expect(n.from).toBe('publicPicker'); + expect(n.to).toBe('(removed)'); + } + expect(JSON.stringify(stack)).not.toContain('publicPicker'); + }); + + it('control: a form without the key is handed back by reference, with no notice — and a list payload is never walked', () => { + const clean = { + views: [{ + object: 'crm_inquiry', + // A LIST payload whose column happens to spell the key is not a form field. + list: { type: 'grid', columns: [{ field: 'subject', publicPicker: {} }] }, + form: { sections: [{ fields: ['subject', { field: 'contact', required: true }] }] }, + }], + }; + const { stack, notices } = collectConversionNotices(clean, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === 'form-field-public-picker-removed')).toEqual([]); + expect(stack).toBe(clean); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = collectConversionNotices( + { views: [viewItem([{ field: 'contact', publicPicker: PICKER }])] }, + { includeRetired: true }, + ); + const twice = collectConversionNotices(once.stack, { includeRetired: true }); + expect(twice.notices).toHaveLength(0); + expect(twice.stack).toBe(once.stack); + }); + + it('a STORED view row carrying the key replays clean through the rehydration seam, and the write door accepts the result', () => { + const stored = viewItem(['subject', { field: 'contact', publicPicker: PICKER }]); + // Before: the retired key is refused at parse — the row a pre-retirement + // author left behind would be badged invalid without the replay. + expect(ViewMetadataSchema.safeParse(stored).success).toBe(false); + const converted = applyConversionsToStoredItem('view', stored) as ReturnType; + expect(converted.config.sections[0]!.fields).toEqual(['subject', { field: 'contact' }]); + expect(ViewMetadataSchema.safeParse(converted).success).toBe(true); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { views: [viewItem([{ field: 'contact', publicPicker: PICKER }])] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === 'form-field-public-picker-removed')).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('form field publicPicker retirement — ADR-0087 registration', () => { + it('declares the key under major 18, wires the D2 into the step-18 chain and carries the family D3 entry', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/FormField:publicPicker'); + const step = MIGRATIONS_BY_MAJOR[18]!; + expect(step.conversionIds).toContain('form-field-public-picker-removed'); + const d3 = step.semantic.find((s) => s.id === 'form-field-public-picker-retired'); + expect(d3, 'the family D3 entry').toBeDefined(); + // The D3 entry names its D2 by its whole id. + expect(d3!.reason).toContain('`form-field-public-picker-removed`'); + expect(d3!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on +// `FormFieldInput`, so every typed authoring site fails to compile. The residue +// is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, +// and TS literals typed `unknown` (a test body handed to a write door). This +// walk covers that residue across five roots, each already declared for +// `@objectstack/spec` in `scripts/cross-package-test-inputs.mjs` and mirrored +// in `turbo.json` — the same roots and extensions the RLS `tags` pin walks. +// +// The key name is unique in this tree, so the matcher is textual and needs no +// structure: an offender is `publicPicker` in KEY position — an object or +// mapping key followed by `:` (optionally quoted, optionally `?`). Prose that +// merely names the key (a comment, a docblock, a Markdown paragraph) is not an +// authoring; in MD/MDX only fenced code is judged. +// +// The bound, stated: a key built by spread or computed name is invisible to a +// text walk; `docs/**`, `.claude/**`, `.github/**`, `.changeset/**` and the +// repo-root files are outside the radius. +describe('tree-scoped absence: no form field inside the declared radius still authors publicPicker', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** + * Structural exclusions — the retirement kit and the pins that author the + * retired key on purpose, each with its reason. ⛔ NOT an allowlist file + * (`spec-property-retirement` §4): every entry's JOB is to spell the key. + */ + const EXCLUDED = new Set([ + // The tombstone itself: `publicPicker: retiredKey(…)` on the form field shape. + 'packages/spec/src/ui/view.zod.ts', + // The union-door pin authors the retired key to prove its prescription + // reaches the author through `ViewMetadataSchema`, not the container text. + 'packages/spec/src/ui/view-union-branch-focus.test.ts', + // The REST strip pin authors it on a STORED pre-retirement row, the exact + // row the old opt-in would have kept, to prove the strip is unconditional. + 'packages/rest/src/public-form-routes.test.ts', + // This pin names the key to assert its absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement field on purpose. + 'packages/spec/src/conversions/', + // GITIGNORED build output reached only because this is a FILESYSTEM walk: + // `retiredKey()` emits the tombstone into the generated JSON Schema's + // `properties`. Its source, `view.zod.ts`, is excluded above for the same reason. + 'packages/spec/json-schema/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** `publicPicker` in key position: optionally quoted, optionally `?`, then `:`. */ + const KEY_POSITION = /(?:^|[^\w$`.])["']?publicPicker["']?\s*\??\s*:/; + + const lineOffenders = (text: string): number[] => + text.split('\n').flatMap((line, idx) => (KEY_POSITION.test(line) ? [idx + 1] : [])); + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const offset = text.slice(0, m.index).split('\n').length; + for (const h of lineOffenders(m[1]!)) out.push(offset + h); + } + return out; + }; + + const offendersIn = (ext: string, text: string): number[] => { + if (!text.includes('publicPicker')) return []; + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lineOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds an authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the key in each syntax the walk reads. + expect(offendersIn('.ts', "fields: [{ field: 'owner', publicPicker: { displayFields: ['name'] } }]")).toEqual([1]); + expect(offendersIn('.ts', "const f = {\n field: 'owner',\n publicPicker: {},\n};")).toEqual([3]); + expect(offendersIn('.ts', 'type T = { publicPicker?: unknown };')).toEqual([1]); + expect(offendersIn('.json', '{ "field": "owner", "publicPicker": { "maxResults": 5 } }')).toEqual([1]); + expect(offendersIn('.yaml', 'fields:\n - field: owner\n publicPicker:\n maxResults: 5\n')).toEqual([3]); + expect(offendersIn('.md', "Prose.\n\n```ts\n{ field: 'owner', publicPicker: {} }\n```\n")).toEqual([4]); + // Neighbours that must NOT match. + // Prose and comments that NAME the key. + expect(offendersIn('.md', 'The `publicPicker` block was removed.')).toEqual([]); + expect(offendersIn('.ts', '// the retired `publicPicker` block is refused')).toEqual([]); + // A dotted path, a registry id and a backticked mention are not keys. + expect(offendersIn('.ts', "surface: 'view.form.sections[].fields[].publicPicker — the picker'")).toEqual([]); + expect(offendersIn('.json', '"ui/FormField:publicPicker [RETIRED]"')).toEqual([]); + expect(offendersIn('.ts', "'`view.form.sections[].fields[].publicPicker` was removed'")).toEqual([]); + // A longer identifier that merely contains the name. + expect(offendersIn('.ts', 'const formFieldPublicPickerRemoved: X = {};')).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no form field authoring publicPicker survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let formBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (text.includes('allowAnonymous')) formBearing += 1; + for (const lineNo of offendersIn(ext, text)) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold a + // public form were really read. + expect(visited).toBeGreaterThan(1000); + expect(formBearing).toBeGreaterThan(10); + expect(offenders, 'a form field authoring `publicPicker` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/view-public-picker.test.ts b/packages/spec/src/ui/view-public-picker.test.ts deleted file mode 100644 index e53dab83074..00000000000 --- a/packages/spec/src/ui/view-public-picker.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#7467] `publicPicker` is DECLARABLE — the acceptance surface for the - * public-lookup opt-in, through the real parse doors. - * - * `GET /forms/:slug/lookup/:field` (`packages/rest/src/rest-server.ts`) has - * always gated the anonymous picker on a `publicPicker` block on the field's - * form declaration — and until #7467 that key was declared in NO schema, so - * `FormFieldSchema`'s strictness (ADR-0089 D3a) refused every form carrying - * one: the capability was enforced but unreachable, ADR-0049's "declared ≠ - * enforced" in the mirror direction. The maintainer ruled DECLARE (option 1 of - * the card's fork), and these tests pin both halves of that ruling: - * - * - the card's measured-rejected fixture now parses, through the SAME doors - * that refused it (`ViewMetadataSchema.safeParse` on the ViewItem branch, - * and `FormViewSchema.parse` for code-authored forms); - * - the block admits NOTHING the route does not enforce — unknown subkeys are - * still `unrecognized_keys` (loud, ADR-0089 D3a), and the route's hard - * bounds are encoded: `maxResults` beyond the route's ceiling of 50 and - * `displayFields` beyond the 5 the route projects are refused at authoring - * time instead of silently clamped/sliced at request time. This is a - * security-relevant surface (it opens an UNAUTHENTICATED search), so the - * schema is deliberately the narrow mirror of the route's reads. - * - * [#7485] The mirror is now exact in both directions: the route's fifth read - * (`picker.sort`) was RETIRED rather than declared, so the four keys above are - * the whole contract and the `sort` pin below states "not declarable AND not - * read" instead of "undeclared, pending a ruling". - */ - -import { describe, expect, it } from 'vitest'; -import { FormViewSchema, ViewMetadataSchema } from './view.zod'; - -/** The card's fixture, verbatim: measured `unrecognized_keys`-rejected on main @ 08363a09f. */ -const CARD_FIELD = { field: 'owner', publicPicker: { displayFields: ['name'] } }; - -/** A ViewItem-branch form carrying one declared field. */ -const viewItem = (field: unknown) => ({ - name: 'lead.contact', - object: 'lead', - viewKind: 'form', - label: 'Contact us', - config: { - type: 'simple', - data: { provider: 'object', object: 'lead' }, - sections: [{ label: 'About you', fields: [field] }], - }, -}); - -describe('#7467 the card\'s measured-rejected fixture now parses', () => { - it('ViewMetadataSchema (ViewItem branch) accepts { field: owner, publicPicker: { displayFields: [name] } }', () => { - const r = ViewMetadataSchema.safeParse(viewItem(CARD_FIELD)); - expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); - }); - - it('FormViewSchema.parse — the code-authored door is the same door', () => { - const parsed = FormViewSchema.parse({ - type: 'simple', - sections: [{ label: 'About you', fields: [CARD_FIELD] }], - }); - const field: any = parsed.sections?.[0]?.fields?.[0]; - expect(field.publicPicker).toEqual({ displayFields: ['name'] }); - }); - - it('an EMPTY block is a valid opt-in — the route supplies its own defaults', () => { - // The route treats any truthy `publicPicker` as the opt-in and defaults - // displayFields to ['name'] and maxResults to 20. `{}` is therefore a - // real declaration, not a mistake — refusing it would make the shortest - // correct authoring spelling a 422. - const r = ViewMetadataSchema.safeParse(viewItem({ field: 'owner', publicPicker: {} })); - expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); - }); - - it('the full surface parses: displayFields + maxResults + filter + object — the route\'s exact read set', () => { - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { - displayFields: ['name', 'email'], - maxResults: 50, - filter: [{ field: 'is_active', operator: 'equals', value: true }], - object: 'sys_user', - }, - })); - expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); - }); - - it('GUARD: a bare field declaration without a picker still parses — the opt-in stays an opt-in', () => { - const r = ViewMetadataSchema.safeParse(viewItem({ field: 'owner' })); - expect(r.success).toBe(true); - }); -}); - -describe('#7467 the block admits nothing the route does not enforce', () => { - it('an unknown subkey is still `unrecognized_keys` — loud per ADR-0089 D3a, not dropped', () => { - // The nearest-miss an author will actually type: pagination does not - // exist on this surface (the route pins offset to 0 so an anonymous - // visitor cannot enumerate the table). It must be a parse error, not a - // silently-dropped key that ships an author a false setting. - const r = FormViewSchema.safeParse({ - type: 'simple', - sections: [{ - label: 'About you', - fields: [{ field: 'owner', publicPicker: { displayFields: ['name'], offset: 10 } }], - }], - }); - expect(r.success).toBe(false); - const issues = r.error?.issues ?? []; - expect(JSON.stringify(issues)).toContain('unrecognized_keys'); - expect(JSON.stringify(issues)).toContain('offset'); - }); - - it('maxResults: 51 is refused — the route clamps to a hard ceiling of 50, so 51 is a lie', () => { - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { maxResults: 51 }, - })); - expect(r.success).toBe(false); - }); - - it('maxResults: 0, negatives and fractions are refused — the route would never honor them', () => { - // The route computes Math.min(Math.max(1, Number(v) || 20), 50): 0 is - // falsy and becomes the default, negatives clamp to 1, and a fraction - // would flow into `limit`. None of those spellings ever executes as - // written, so authoring one is refused instead of silently rewritten. - for (const maxResults of [0, -5, 2.5]) { - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { maxResults }, - })); - expect(r.success, `maxResults: ${maxResults} must be refused`).toBe(false); - } - }); - - it('a 6th displayField is refused — the route projects at most 5', () => { - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { displayFields: ['a', 'b', 'c', 'd', 'e', 'f'] }, - })); - expect(r.success).toBe(false); - }); - - it('displayFields: [] is refused — the route treats an empty list as absent, so it never means what it says', () => { - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { displayFields: [] }, - })); - expect(r.success).toBe(false); - }); - - it('a malformed filter row is refused through the shared rule dialect (#6227 shape coupling included)', () => { - // `filter` reuses ViewFilterRuleSchema — the same dialect the route - // composes with its own `{ field, operator: 'contains', value: q }` - // search row. A set operator with a scalar comparand is the #6227 - // authoring defect; it must be refused here exactly as on every other - // filter carrier, not deferred to a public 400 at request time. - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { filter: [{ field: 'stage', operator: 'not_in', value: 'won' }] }, - })); - expect(r.success).toBe(false); - }); - - it('[#7485] picker.sort is NOT declarable AND not read — the route no longer has a fifth key', () => { - // The route USED to read `picker.sort` (rest-server.ts, the lookup - // handler's findData call) — a fifth read the #7467 card's enumeration - // did not include, which left `sort` enforced by the route and - // authorable nowhere. #7485 closed that mirror-gap by REMOVAL: the - // maintainer ruled retire-the-read over declare-the-key, so the route - // now always sorts by `[{ field: displayFields[0], order: 'asc' }]` and - // this rejection is no longer a deferred decision — it is the whole - // contract. `sort` is not declarable because nothing reads it. - // - // Both halves are pinned: this one, and the route side in - // `packages/rest/src/public-form-lookup-picker.test.ts` (a stored row - // carrying `sort` is ignored, and the composed query still gets the - // fixed default). Declaring the key again means re-running the fork on - // #7485 — and re-teaching the route to read it, which is the harder half. - const r = ViewMetadataSchema.safeParse(viewItem({ - field: 'owner', - publicPicker: { sort: [{ field: 'name', order: 'asc' }] }, - })); - expect(r.success).toBe(false); - const issues = (r as any).error?.issues ?? []; - expect(JSON.stringify(issues)).toContain('unrecognized_keys'); - expect(JSON.stringify(issues)).toContain('sort'); - }); -}); diff --git a/packages/spec/src/ui/view-union-branch-focus.test.ts b/packages/spec/src/ui/view-union-branch-focus.test.ts index 120693d79aa..2e4d108cb45 100644 --- a/packages/spec/src/ui/view-union-branch-focus.test.ts +++ b/packages/spec/src/ui/view-union-branch-focus.test.ts @@ -23,15 +23,22 @@ * all along — Prime Directive #12's failure mode, on the door Studio uses. * * It is not a `publicPicker` quirk. The four bodies in `MISDIRECTED` below are - * four different ViewItem-branch failures — a picker subkey, an unknown - * form-field key, a bad field enum, a typo'd column summary — and on `main` all - * four surfaced that same container text, because the cause is structural: the + * four different ViewItem-branch failures — and on `main` all four surfaced + * that same container text, because the cause is structural: the * shared ranking scores a branch by `[issue count, carries unrecognized_keys]`, * the container branch always reports exactly ONE root `unrecognized_keys` * (`viewKind`/`config` are not container keys), and the ViewItem branch reports * exactly ONE nested `invalid_union` whose real key sits a level below where the * tiebreak looks. * + * [#21180] The measured repro rode on `publicPicker`, which ruling E on #21079 + * retired (comment 5933054144): the key is now a `retiredKey()` tombstone, so a + * subkey inside it can no longer be "unknown". The nested-subkey case moved to + * `keyField`, the other strict block a form field carries, and the retired key + * ITSELF became a case of its own — its prescription must reach the author + * through this door too, not the container text. The picker-carrying ACCEPTED + * body moved to REFUSED (§3), the one deliberate verdict move since #7741. + * * ## What this file pins * * 1. Each of those four now surfaces the ViewItem branch, naming the key the @@ -124,14 +131,14 @@ function rendered(body: unknown): string { */ const MISDIRECTED: Array<[string, unknown, string]> = [ [ - 'the card\'s repro — an unknown `publicPicker` subkey', - formItem({ field: 'owner', publicPicker: { displayFields: ['name'], sort: [{ field: 'email', order: 'desc' }] } }), + 'the card\'s repro, on `keyField` — an unknown subkey inside a nested form-field block', + formItem({ field: 'owner', keyField: { field: 'name', sort: [{ field: 'email', order: 'desc' }] } }), 'sort', ], [ - '#7467\'s `offset` case, through the union door this time', - formItem({ field: 'owner', publicPicker: { displayFields: ['name'], offset: 10 } }), - 'offset', + '[#21180] the retired `publicPicker` key itself — its prescription, not the container text', + formItem({ field: 'owner', publicPicker: { displayFields: ['name'] } }), + 'publicPicker', ], [ 'an unknown key on the form FIELD itself', @@ -177,7 +184,7 @@ describe('[#7510] a ViewItem-branch failure surfaces the ViewItem branch', () => it('the byte-identical body minus the bad subkey still saves', () => { // The card's own control: this is what makes the rejection a diagnostic // problem rather than an acceptance one. - const r = ViewMetadataSchema.safeParse(formItem({ field: 'owner', publicPicker: { displayFields: ['name'] } })); + const r = ViewMetadataSchema.safeParse(formItem({ field: 'owner', keyField: { field: 'name' } })); expect(r.success, JSON.stringify((r as any).error?.issues)).toBe(true); }); }); @@ -212,7 +219,7 @@ describe('[#7510] the container diagnostic still belongs to containers', () => { }); describe('[#7510] the union\'s error payload is focused, never reshaped', () => { - const CLAIMED = formItem({ field: 'owner', publicPicker: { displayFields: ['name'], sort: [] } }); + const CLAIMED = formItem({ field: 'owner', keyField: { field: 'name', sort: [] } }); it('keeps four branches, in position — a positional consumer still finds its member', () => { const issue = ViewMetadataSchema.safeParse(CLAIMED).error!.issues[0] as unknown as { @@ -263,8 +270,14 @@ describe('[#7510] ⛔ the acceptance face did not move', () => { // now, and their unbound originals are pinned as REFUSED below. #7510's own // claim — focusing never changes a verdict — is unaffected and still pinned // by the rest of this corpus. + // + // [#21180] A second deliberate, RULED move (ruling E on #21079): the form + // field's `publicPicker` is retired, so the picker-carrying body that opened + // this list is now REFUSED (pinned below, with the MISDIRECTED case that + // shows its prescription reaching the author). Its slot here is taken by the + // same form item with a `keyField` block, the nested control. const ACCEPTED: unknown[] = [ - formItem({ field: 'owner', publicPicker: { displayFields: ['name'] } }), + formItem({ field: 'owner', keyField: { field: 'name' } }), { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, { object: 'crm_lead', list: { type: 'grid', columns: ['name'] } }, { object: 'crm_lead', formViews: { my: { type: 'simple' } } }, @@ -279,6 +292,8 @@ describe('[#7510] ⛔ the acceptance face did not move', () => { [{ type: 'simple' }, ['invalid_union']], [{ isPinned: true }, ['invalid_union']], [MISDIRECTED[0]![1], ['invalid_union']], + // [#21180] the retired picker key, refused by its tombstone. + [MISDIRECTED[1]![1], ['invalid_union']], [MISDIRECTED[3]![1], ['invalid_union']], [{ name: 'a.b', object: 'a', viewKind: 'chart', config: { type: 'grid', columns: ['name'] } }, ['invalid_union']], [{ object: 'crm_lead', listViews: {} }, ['custom']], diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 60cb550dd31..9f7ff0ebc4f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3139,94 +3139,32 @@ export const FormSelectOptionSchema = lazySchema(() => { * so a closed parent said nothing about it. */ /** - * [#7467] Public-lookup opt-in for a lookup / `master_detail` / `user` field on - * an ANONYMOUS public form. - * - * This block GATES the REST public-lookup capability: `GET - * /forms/:slug/lookup/:field` answers a picker search only for a field whose - * form declaration carries `publicPicker`. Without it the route answers `403 - * LOOKUP_NOT_PUBLIC` — loud by design (#3022), so a misconfigured form is a - * visible refusal rather than a silently empty picker. The public-form resolve - * route enforces the same opt-in from the other side: an undeclared - * lookup/master_detail/user field is stripped from the rendered sections, so an - * anonymous form can never expose unrestricted record search by accident. - * - * Until #7467 this key was ENFORCED but declared nowhere — the mirror image of - * ADR-0049's "declared ≠ enforced": `FormFieldSchema` is strict (ADR-0089 D3a), - * so every authoring path refused a form carrying a picker and the capability - * was unreachable. Declaring it is the maintainer-ruled direction (option 1 of - * that card's fork). - * - * Every key below mirrors a read the route actually performs - * (`packages/rest/src/rest-server.ts`, `GET /forms/:slug/lookup/:field` - * handler), and NOTHING else: this block opens an unauthenticated search - * surface, so the schema deliberately admits no option the route does not - * enforce. The route's own hard bounds are encoded rather than restated in - * prose — `displayFields` beyond the first 5 are never projected (the route - * slices), a `maxResults` above 50 is never honored (the route clamps), so - * authoring either is refused here instead of silently meaning less than it - * says. Anonymous visitors can search but cannot paginate (`offset` is pinned - * to 0 server-side), which is what keeps a leaked endpoint from enumerating - * the table. - * - * @example Opt a lookup field into the public picker - * { field: 'owner', publicPicker: { displayFields: ['name'], maxResults: 10 } } - */ -export const FormFieldPublicPickerSchema = lazySchema(() => strictObject({ - surface: 'this public picker configuration', - history: VIEW_HISTORY, -}, { - /** - * Projection: the fields returned for each picker row (plus `id`), and the - * search target — the visitor's `q` is matched with `contains` against the - * FIRST entry. The route projects at most 5 and defaults to `['name']` when - * omitted, so more than 5 (or an empty list) is refused here rather than - * silently truncated / silently replaced. - */ - displayFields: z.array(z.string()).min(1).max(5).optional().describe( - 'Fields projected into each picker result (with `id`); the visitor\'s search matches ' - + '`contains` on the first entry. At most 5 (the route projects no more); omitted → [\'name\'].', - ), - /** - * Per-request result cap. The route clamps to a hard ceiling of 50 and - * defaults to 20; anonymous visitors cannot paginate, so this bounds what a - * single request can pull. Values the route would never honor (0, negatives, - * fractions, > 50) are refused at authoring time. - */ - maxResults: z.number().int().min(1).max(50).optional().describe( - 'Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous ' - + 'visitors cannot paginate past it).', - ), - /** - * Static pre-filter, ANDed ahead of the visitor's search predicate. Same - * rule dialect as every other view filter (`ViewFilterRuleSchema`) — the - * route composes these rows with its own `{ field, operator: 'contains', - * value: q }` search row in one filters list. - */ - filter: z.array(ViewFilterRuleSchema).optional().describe( - 'Static pre-filter rows ANDed ahead of the visitor\'s search (e.g. only active records are ' - + 'searchable). Same `{ field, operator, value }` dialect as list-view filters.', - ), - /** - * Referenced-object override. Omitted, the route resolves the target from - * the field definition on the parent object (`reference`); set it only when - * that resolution is wrong for this form. - * - * `reference` is the key `FieldSchema` accepts — `referenceTo` is only a - * rejected alias it lists so a failed parse can offer a rename hint, so an - * author following the old spelling of this sentence had their whole object - * metadata refused at parse. - */ - object: z.string().optional().describe( - 'Referenced-object override for the picker search; omitted → resolved from the `reference` ' - + 'key on the field definition.', - ), -}).describe('Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on an anonymous public form (without it the route answers 403 LOOKUP_NOT_PUBLIC).')); - -/** Authoring shape of {@link FormFieldPublicPickerSchema}. */ -export type FormFieldPublicPicker = z.input; -/** Post-parse shape of {@link FormFieldPublicPicker} — filter-rule operator aliases folded (ADR-0122). */ -export type FormFieldPublicPickerParsed = z.infer; + * [#21180] Prescription for the retired `publicPicker` block on a form field + * (ADR-0087 D2, immediate retirement, no alias window). + * + * The block opted a lookup / `master_detail` / `user` field on an ANONYMOUS + * public form into a record-search picker served by an unauthenticated route + * (`GET /forms/:slug/lookup/:field`). The maintainer's ruling E on #21079 + * (comment 5933054144) retired the capability outright and reversed the + * #7467 ruling that had declared the key: anonymous public forms no longer + * take lookup, `master_detail` or `user` fields at all, and the route is + * deleted. What survives is the resolve route's strip, now unconditional — + * those three field types are always left off the anonymous rendering — so a + * form cannot expose record search to the internet by any declaration. + * + * The key stays in the shape as a {@link retiredKey} tombstone so `tsc` types + * it `never` and the parse refuses it with this text instead of a bare + * unrecognized-key report; `form-field-public-picker-removed` + * (`conversions/registry.ts`) strips it from stored sources. + */ +const FORM_FIELD_PUBLIC_PICKER_RETIRED = + '`view.form.sections[].fields[].publicPicker` was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) ' + + '— an anonymous public form no longer offers record search: lookup, `master_detail` and `user` ' + + 'fields are always left off the anonymous rendering, and the anonymous record-search route ' + + '(`GET /forms/:slug/lookup/:field`) no longer exists. Delete the key (the whole `publicPicker` ' + + 'block). To let a visitor choose from a fixed list, use a `select` field with static `options`; ' + + 'to let them pick an existing record, put the form behind sign-in. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; const FormFieldBaseSchema = lazySchema(() => { const shape = { @@ -3266,17 +3204,12 @@ const FormFieldBaseSchema = lazySchema(() => { reference: z.string().optional().describe('Target object name for lookup/master_detail fields'), /** - * [#7467] Public-lookup opt-in (lookup / master_detail / user fields on an - * anonymous public form). Gates `GET /forms/:slug/lookup/:field` — absent, - * the route answers 403 LOOKUP_NOT_PUBLIC (loud by design, #3022) and the - * resolve route strips the field from the rendered sections. See - * {@link FormFieldPublicPickerSchema}. + * [#21180] RETIRED — the anonymous public-form record-search picker. See + * {@link FORM_FIELD_PUBLIC_PICKER_RETIRED}: the parse refuses the key with + * that prescription, and a public form's lookup / `master_detail` / `user` + * fields are always left off its anonymous rendering. */ - publicPicker: FormFieldPublicPickerSchema.optional().describe( - 'Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). ' - + 'Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the ' - + 'rendered public form.', - ), + publicPicker: retiredKey(FORM_FIELD_PUBLIC_PICKER_RETIRED), /** Text constraints */ // #12174 — the form-field row's constraint keys converge on the value shape diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index e0c672647d7..8a11cb0dd7c 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -43,6 +43,7 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/form-field-public-picker-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts" diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3d931266e8e..d98f344dfeb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3461,36 +3461,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/rest/src/public-form-lookup-filter-lowering.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/rest/src/public-form-lookup-filter-lowering.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/rest/src/public-form-lookup-filter-lowering.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/rest/src/public-form-lookup-picker.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/rest/src/public-form-lookup-picker.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/rest/src/public-form-lookup-picker.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/rest/src/public-form-routes.stored-row.test.ts", "verb": "delete", From 52903dbd44833d05676f501472e47a93a1e83069 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:57:18 +0000 Subject: [PATCH 02/10] chore(spec): register the retired FormFieldPublicPicker def and regenerate the spec artefacts The def leaves with its only carrier: a RETIRED_DEFS_BY_MAJOR[18] entry, its manifest and authorable-surface lines deleted deliberately, the dropped-refinements ledger corrected as the build printed it, and the api-surface, export-origins, declaration-map, reference docs and strictness counts regenerated by check:generated --fix. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 4 +- .../docs/references/api/error-code-ledger.mdx | 2 - content/docs/references/index.mdx | 10 ++--- content/docs/references/ui/view.mdx | 43 ++----------------- .../ui.md | 12 +++--- packages/spec/api-surface/ui.json | 3 -- packages/spec/authorable-surface/ui.json | 6 +-- packages/spec/declaration-map/ui.json | 2 - .../spec/dropped-refinements.baseline.json | 14 +----- packages/spec/export-origins/ui.json | 3 -- packages/spec/json-schema.manifest/ui.json | 1 - .../18.ui__FormFieldPublicPicker.ts | 10 +++++ packages/spec/src/migrations/registry.ts | 8 ++++ 13 files changed, 36 insertions(+), 82 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.ui__FormFieldPublicPicker.ts diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index f6d7d3f6c25..7c05cfac3a8 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +320 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -218,8 +218,6 @@ const result = ApiErrorSchema.parse(data); * `IP_NOT_ALLOWED` * `ITEM_LOCKED` * `LAST_LOCAL_CREDENTIAL` -* `LOOKUP_NOT_PUBLIC` -* `LOOKUP_TARGET_MISSING` * `MANIFEST_CONFLICT` * `MAPPING_FORMAT_MISMATCH` * `MAPPING_FORMAT_UNSUPPORTED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 4060ec19c7f..04a8c057b53 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -385,8 +385,6 @@ const result = ErrorCode.parse(data); * `IP_NOT_ALLOWED` * `ITEM_LOCKED` * `LAST_LOCAL_CREDENTIAL` -* `LOOKUP_NOT_PUBLIC` -* `LOOKUP_TARGET_MISSING` * `MANIFEST_CONFLICT` * `MAPPING_FORMAT_MISMATCH` * `MAPPING_FORMAT_UNSUPPORTED` diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0f71bf70185..0e60bfa2659 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1522** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **197** | **1521** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 166 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 165 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -385,7 +385,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `EmptyState`, `FormButtonConfig`, `FormField`, `FormSection`, `FormSelectOption`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e173131c7d9..cfae8a52fed 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -59,8 +59,8 @@ nothing. The one item-name grammar itself lives in ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -197,7 +197,7 @@ Column footer summary configuration | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | Field type (auto-infers widget if omitted) | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | -| **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | +| **publicPicker** | `never` | optional | [REMOVED] `view.form.sections[].fields[].publicPicker` was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, `master_detail` and `user` fields are always left off the anonymous rendering, and the anonymous record-search route (`GET /forms/:slug/lookup/:field`) no longer exists. Delete the key (the whole `publicPicker` block). To let a visitor choose from a fixed list, use a `select` field with static `options`; to let them pick an existing record, put the form behind sign-in. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | | **minLength** | `integer` | optional | Minimum character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key) | | **min** | `number` | optional | Minimum value (for number/currency/percent/slider) | @@ -287,15 +287,6 @@ Form-view select option — the object-field option shape minus the per-option ` | **color** | `string` | optional | Color code for badges/charts | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | -### Nested Shape: `FormField.publicPicker` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | -| **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | -| **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the `reference` key on the field definition. | - ### Nested Shape: `FormField.keyField` | Property | Type | Required | Description | @@ -308,32 +299,6 @@ Form-view select option — the object-field option shape minus the per-option ` | **immutable** | `boolean` | optional (default: `true`) | If true, the key is read-only after creation | ---- - -## FormFieldPublicPicker - -Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on an anonymous public form (without it the route answers 403 LOOKUP_NOT_PUBLIC). - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | -| **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | -| **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the `reference` key on the field definition. | - -### Nested Shape: `FormFieldPublicPicker.filter[number]` - -View filter rule - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - - --- ## FormSection @@ -362,7 +327,7 @@ View filter rule | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | Field type (auto-infers widget if omitted) | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by `defineForm`), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option `value` is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits `options`: the control derives the members from the served JSON Schema, and their meanings go in `helpText`. | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | -| **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | +| **publicPicker** | `never` | optional | [REMOVED] `view.form.sections[].fields[].publicPicker` was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, `master_detail` and `user` fields are always left off the anonymous rendering, and the anonymous record-search route (`GET /forms/:slug/lookup/:field`) no longer exists. Delete the key (the whole `publicPicker` block). To let a visitor choose from a fixed list, use a `select` field with static `options`; to let them pick an existing record, put the form behind sign-in. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | | **minLength** | `integer` | optional | Minimum character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key) | | **min** | `number` | optional | Minimum value (for number/currency/percent/slider) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index ecb6edcf963..d344801444f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 188 | 178 | 3 | 0 | 7 | +| `ui/` | 187 | 177 | 3 | 0 | 7 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 62 | +| `view.zod.ts` | 61 | | `widget.zod.ts` | 1 | -| **total** | **188** | +| **total** | **187** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 188**, in 4 file(s). +**7 strip of 187**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 62 | +| `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **188** | +| **total** | **7** | **187** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 6c761e1610b..abaa2b16c75 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -190,9 +190,6 @@ "FormButtonConfigSchema (const)", "FormField (type)", "FormFieldInput (type)", - "FormFieldPublicPicker (type)", - "FormFieldPublicPickerParsed (type)", - "FormFieldPublicPickerSchema (const)", "FormFieldSchema (const)", "FormSection (type)", "FormSectionParsed (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index fe2af4a331d..e2011667fd0 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -537,7 +537,7 @@ "ui/FormField:options", "ui/FormField:placeholder", "ui/FormField:precision", - "ui/FormField:publicPicker", + "ui/FormField:publicPicker [RETIRED]", "ui/FormField:readonly", "ui/FormField:reference", "ui/FormField:required", @@ -547,10 +547,6 @@ "ui/FormField:visibleOn", "ui/FormField:visibleWhen", "ui/FormField:widget", - "ui/FormFieldPublicPicker:displayFields", - "ui/FormFieldPublicPicker:filter", - "ui/FormFieldPublicPicker:maxResults", - "ui/FormFieldPublicPicker:object", "ui/FormSection:collapsed", "ui/FormSection:collapsible", "ui/FormSection:columns", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 7477d4f958c..dc8e3418a2d 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -122,8 +122,6 @@ "FormButtonConfigSchema": "ui/FormButtonConfig", "FormField": "ui/FormField", "FormFieldBaseSchema": "ui/FormField", - "FormFieldPublicPicker": "ui/FormFieldPublicPicker", - "FormFieldPublicPickerSchema": "ui/FormFieldPublicPicker", "FormFieldSchema": "ui/FormField", "FormSection": "ui/FormSection", "FormSectionSchema": "ui/FormSection", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 0de62491174..c0db849b453 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1215,27 +1215,15 @@ "filter.element" ] }, - "ui/FormField": { - "sites": [ - "in.publicPicker.filter.element" - ] - }, - "ui/FormFieldPublicPicker": { - "sites": [ - "filter.element" - ] - }, "ui/FormSection": { "sites": [ - "in", - "in.fields.element.options[1].in.publicPicker.filter.element" + "in" ] }, "ui/FormView": { "sites": [ "", "sections.element.in", - "sections.element.in.fields.element.options[1].in.publicPicker.filter.element", "subforms.element.columns.element", "submitBehavior.options[1].url" ] diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 8a8cc9fb351..f3235391489 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -186,9 +186,6 @@ "FormButtonConfigSchema": "src/ui/view.zod.ts#FormButtonConfigSchema (const)", "FormField": "src/ui/view.zod.ts#FormField (type)", "FormFieldInput": "src/ui/view.zod.ts#FormFieldInput (type)", - "FormFieldPublicPicker": "src/ui/view.zod.ts#FormFieldPublicPicker (type)", - "FormFieldPublicPickerParsed": "src/ui/view.zod.ts#FormFieldPublicPickerParsed (type)", - "FormFieldPublicPickerSchema": "src/ui/view.zod.ts#FormFieldPublicPickerSchema (const)", "FormFieldSchema": "src/ui/view.zod.ts#FormFieldSchema (const)", "FormSection": "src/ui/view.zod.ts#FormSection (type)", "FormSectionParsed": "src/ui/view.zod.ts#FormSectionParsed (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index cf577ded7ec..9ba896e997a 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -67,7 +67,6 @@ "ui/ExpressionBindableTextKey", "ui/FormButtonConfig", "ui/FormField", - "ui/FormFieldPublicPicker", "ui/FormSection", "ui/FormSelectOption", "ui/FormView", diff --git a/packages/spec/src/migrations/entries/retired-defs/18.ui__FormFieldPublicPicker.ts b/packages/spec/src/migrations/entries/retired-defs/18.ui__FormFieldPublicPicker.ts new file mode 100644 index 00000000000..104f0f1c9bf --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.ui__FormFieldPublicPicker.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `ui/FormFieldPublicPicker` (`displayFields`, `maxResults`, `filter`, +// `object`) leaves with its only carrier, `FormFieldBaseSchema.publicPicker`, +// tombstoned in this same major under ADR-0087 D2 by the maintainer's ruling E +// on #21079: anonymous public forms no longer offer record search, so nothing +// replaces the shape — a fixed choice is a `select` field with static +// `options`, and a record choice belongs on a form behind sign-in. See +// `retired-keys/18.ui__FormField__publicPicker.ts` for the retirement record. +export const entry = 'ui/FormFieldPublicPicker'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a04a9b52b02..4f83ad5b544 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -25516,6 +25516,14 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // exported value schema with no consumer reads as a capability). See // `18.ui__Theme.ts` for the retirement record and the ruling. 'ui/ColorPalette', + // `ui/FormFieldPublicPicker` (`displayFields`, `maxResults`, `filter`, + // `object`) leaves with its only carrier, `FormFieldBaseSchema.publicPicker`, + // tombstoned in this same major under ADR-0087 D2 by the maintainer's ruling E + // on #21079: anonymous public forms no longer offer record search, so nothing + // replaces the shape — a fixed choice is a `select` field with static + // `options`, and a record choice belongs on a form behind sign-in. See + // `retired-keys/18.ui__FormField__publicPicker.ts` for the retirement record. + 'ui/FormFieldPublicPicker', // #11027 — `ui/ResponsiveConfig` (the per-breakpoint LAYOUT block: grid // columns / visibility / display order on the Tailwind `xs…2xl` axis). Its // last authorable carrier, `page.components[].responsive`, is tombstoned in From d3c7167815142f51fb81c1ad969d17afdf4aa277 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:05:06 +0000 Subject: [PATCH 03/10] test(rest): install the adapter's unmatched-request seam in the retired-route pin, as the served composition does Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/rest/src/public-form-routes.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts index f7889c613c5..405d8bd86db 100644 --- a/packages/rest/src/public-form-routes.test.ts +++ b/packages/rest/src/public-form-routes.test.ts @@ -462,9 +462,11 @@ describe('[#21180] GET /forms/:slug/lookup/:field is gone — it answers what an // The anonymous record-search picker route is deleted, not refused: no // registered route matches the path, so the adapter's own unmatched-request // answer is the whole response. Driven through the real `HonoHttpServer` - // (the adapter `os serve` mounts) because "unregistered" is the adapter's - // statement, not a handler's. The control is a sibling path of the same - // shape that never existed. + // (the adapter `os serve` mounts), with the unmatched-request seam installed + // the way `HonoServerPlugin.start()` installs it, because "unregistered" is + // the adapter's statement, not a handler's. The control is a sibling path of + // the same shape that never existed; the lit control is the registered + // resolve route on the same harness, which answers its own envelope. async function answer(path: string) { const server = new HonoHttpServer(0); const protocol: any = { @@ -480,6 +482,7 @@ describe('[#21180] GET /forms/:slug/lookup/:field is gone — it answers what an const rest = new RestServer(server as any, protocol, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); + server.installNotFoundSeam(); const res: Response = await server.getRawApp().fetch(new Request(`http://local${path}`)); return { status: res.status, body: await res.json(), findData: protocol.findData }; } @@ -502,5 +505,9 @@ describe('[#21180] GET /forms/:slug/lookup/:field is gone — it answers what an expect(picker.status).toBe(control.status); expect(JSON.stringify(picker.body).replace('/lookup/', '/never_registered/')).toBe(JSON.stringify(control.body)); expect(picker.findData).not.toHaveBeenCalled(); + // Lit control: the same harness DOES dispatch a registered public-form route. + const resolved = await answer('/api/v1/forms/test'); + expect(resolved.status).toBe(200); + expect(resolved.body?.slug).toBe('test'); }); }); From c9642d91e2a433508e6ededa0866ee9a3c9e7b2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:33:41 +0000 Subject: [PATCH 04/10] =?UTF-8?q?chore(changeset):=20the=20publicPicker=20?= =?UTF-8?q?retirement=20=E2=80=94=20BREAKING,=20FROM=20=E2=86=92=20TO,=20A?= =?UTF-8?q?DR-0087=20registered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also moves the dropped-refinements ledger's header totals with its body (212 → 210 schemas, 617 → 613 sites). Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .changeset/21180-retire-public-picker.md | 41 +++++++++++++++++++ .../spec/dropped-refinements.baseline.json | 4 +- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 .changeset/21180-retire-public-picker.md diff --git a/.changeset/21180-retire-public-picker.md b/.changeset/21180-retire-public-picker.md new file mode 100644 index 00000000000..0ca9b0f2fd6 --- /dev/null +++ b/.changeset/21180-retire-public-picker.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/lint': patch +--- + +**BREAKING** — an anonymous public form no longer offers record search. The form field's `publicPicker` block (`view.form.sections[].fields[].publicPicker`: `displayFields`, `maxResults`, `filter`, `object`) is removed, and the anonymous lookup route `GET /api/v1/forms/:slug/lookup/:field` is deleted. A public form's `lookup`, `master_detail` and `user` fields are now always left off its anonymous rendering, whatever the form declares. + +Clause-②: yes (narrowing) + +Retired immediately (ADR-0087 D2), with no alias window: the maintainer's ruling reverses the earlier one that had declared the key. Mainstream web-to-lead forms do not let an anonymous visitor search records either, and no example, template, plugin or first-party UI declared or called the picker. + +## FROM → TO + +| you wrote (17.5 and earlier) | write instead | +| --- | --- | +| `{ field: 'account', publicPicker: { displayFields: ['name'], maxResults: 10 } }` on a public form | delete the `publicPicker` block — the field is left off the anonymous rendering anyway | +| a public form whose visitors chose from a short, fixed list of records | a `select` field with static `options` listing the choices | +| a public form whose visitors had to pick an existing record | the same form behind sign-in (an internal form), where the lookup field searches with the signed-in user's own access | +| a client calling `GET /api/v1/forms/:slug/lookup/:field` | nothing to call: the path is no longer registered and answers what any unregistered path answers (`404 ENDPOINT_NOT_FOUND`) | + +**The one-line fix:** delete the `publicPicker` block; an anonymous public form no longer offers record search. Use a `select` field with static `options`, or put the form behind sign-in. + +**What an author who still writes it sees.** `tsc` fails at the authoring site (`FormFieldInput` types the key `never`), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `…sections[N].fields[N].publicPicker` with the prescription: + +> `view.form.sections[].fields[].publicPicker` was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, `master_detail` and `user` fields are always left off the anonymous rendering, and the anonymous record-search route (`GET /forms/:slug/lookup/:field`) no longer exists. Delete the key (the whole `publicPicker` block). To let a visitor choose from a fixed list, use a `select` field with static `options`; to let them pick an existing record, put the form behind sign-in. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + +**What a REST client sees.** The two error codes only that route produced, `LOOKUP_NOT_PUBLIC` and `LOOKUP_TARGET_MISSING`, leave the error-code ledger with it. `GET /api/v1/forms/:slug` and `POST /api/v1/forms/:slug/submit` are unchanged apart from the unconditional strip above. + +## The retirement kit + +- **A `retiredKey()` tombstone on the form field**, so the parse carries the prescription instead of a bare unknown-key verdict. The block's own schema and its two types go with it: `FormFieldPublicPickerSchema`, `FormFieldPublicPicker` and `FormFieldPublicPickerParsed` are no longer exported, and `ui/FormFieldPublicPicker` is no longer published as a JSON Schema. +- **The D2 conversion `form-field-public-picker-removed`** (protocol 18, retired from the load path) deletes the key from every form field of every form payload — `sections[]`, `groups[]`, top-level `fields[]` and nested rows. Its D3 record is the semantic entry `form-field-public-picker-retired`, which asks the author how a visitor should now choose. +- **`@objectstack/rest`:** the lookup route and its filter-lowering helper are deleted, and the resolve route's strip of lookup / `master_detail` / `user` fields no longer has an opt-in. +- **`@objectstack/lint`:** the preset-comparand rule no longer reads a picker's `filter` (its claiming reader for that position went with the key). + +## What an operator with a STORED form sees + +A `sys_metadata` view row saved before this release may still carry the key. Nothing breaks at read: the conversion replays on rehydration and strips it, so the view is served canonical and parses, and the field stays off the anonymous rendering either way. `os migrate meta --stored` lists those rows, and `--apply` rewrites them. + + diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index c0db849b453..ca25c301b7b 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 212, - "droppedRefinementSites": 617, + "publishedSchemasWithDroppedRefinements": 210, + "droppedRefinementSites": 613, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, From 390a925c00c493d84f0896f057413d796a738ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:51:29 +0000 Subject: [PATCH 05/10] test(rest): type the retired-route pin's response body like the rest of the file Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/rest/src/public-form-routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts index 405d8bd86db..e18bc9149b7 100644 --- a/packages/rest/src/public-form-routes.test.ts +++ b/packages/rest/src/public-form-routes.test.ts @@ -484,7 +484,7 @@ describe('[#21180] GET /forms/:slug/lookup/:field is gone — it answers what an rest.registerRoutes(); server.installNotFoundSeam(); const res: Response = await server.getRawApp().fetch(new Request(`http://local${path}`)); - return { status: res.status, body: await res.json(), findData: protocol.findData }; + return { status: res.status, body: (await res.json()) as any, findData: protocol.findData }; } it('no route is registered for the path', () => { From 429e5d329966673e8805ddb880671c45293fe4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:40:18 +0000 Subject: [PATCH 06/10] =?UTF-8?q?chore(scripts):=20bank=20the=20route-enve?= =?UTF-8?q?lope=20ratchet=20=E2=80=94=20the=20retired=20picker=20route=20t?= =?UTF-8?q?ook=20four=20legacy=20bodies=20with=20it=20(43=20=E2=86=92=2039?= =?UTF-8?q?,=2058=20=E2=86=92=2054)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- scripts/check-route-envelope.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 6ba259c2431..7b85d2c3565 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -318,7 +318,13 @@ const MODULES = { // 44 → 43 (#20102): the saved-report `/reports` family was retired whole, // and one of its arms spelled a literal string `error`. Deleted, not // converted — banked per the ratchet's own rule. - stringError: 43, + // + // 43 → 39 (#21180): the anonymous public-form lookup picker route + // (`GET /forms/:slug/lookup/:field`) was retired whole by ruling E on + // #21079 — its four `{ code, error }` answers (400 `INVALID_REQUEST`, 404 + // `FORM_NOT_FOUND`, 403 `LOOKUP_NOT_PUBLIC`, 500 `LOOKUP_TARGET_MISSING`) + // left with the handler. Deleted, not converted; banked per the same rule. + stringError: 39, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared // `respondError` helper, banking that progress per the ratchet's own rule. @@ -353,7 +359,10 @@ const MODULES = { // with the saved-report stack — its eleven `{ code, error }` sites (the // `*_FAILED` 500s and `SCHEDULE_DELETE_FAILED`) left with the eight routes. // Deleted rather than converted, so no wire answer that still exists moved. - siblingCode: 58, + // + // 58 → 54 (#21180): the same four picker-route answers as `stringError` + // above, counted again here — the route was deleted, not converted. + siblingCode: 54, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from From dafa22868fdde8b8965bc0b52f226018c238650d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:43:07 +0000 Subject: [PATCH 07/10] chore(docs): regenerate the view reference from the merged tree Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index cfae8a52fed..a1e4db974f3 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -213,7 +213,7 @@ Column footer summary configuration | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `e420df310f5b`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `31971ff1e28f`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -343,7 +343,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `e420df310f5b`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `31971ff1e28f`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | From 388bf08112c05cb953ea2573b0cfb862d8d3fda4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:19:49 +0000 Subject: [PATCH 08/10] chore(spec): regenerate export-origins, api-surface and the strictness counts from the merged tree Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .../2026-07-unknown-key-strictness-ledger.counts/ui.md | 10 +++++----- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index d344801444f..a5d4c08e98a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 187 | 177 | 3 | 0 | 7 | +| `ui/` | 188 | 178 | 3 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 56 | +| `component.zod.ts` | 57 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 61 | | `widget.zod.ts` | 1 | -| **total** | **187** | +| **total** | **188** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 187**, in 4 file(s). +**7 strip of 188**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 61 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **187** | +| **total** | **7** | **188** | | Bucket | Sites | |---|---| diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index abaa2b16c75..010e3620728 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -294,6 +294,7 @@ "ObjectMapPropsParsed (type)", "ObjectMapPropsSchema (const)", "ObjectMasterDetailFormProps (type)", + "ObjectMasterDetailFormPropsParsed (type)", "ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps (type)", "ObjectMetricPropsParsed (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index f3235391489..5caee7c3d2c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -290,6 +290,7 @@ "ObjectMapPropsParsed": "src/ui/component.zod.ts#ObjectMapPropsParsed (type)", "ObjectMapPropsSchema": "src/ui/component.zod.ts#ObjectMapPropsSchema (const)", "ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)", + "ObjectMasterDetailFormPropsParsed": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsParsed (type)", "ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)", "ObjectMetricPropsParsed": "src/ui/component.zod.ts#ObjectMetricPropsParsed (type)", From c3f990d269ac8244b77b804cea82e3b01dc431e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:50:15 +0000 Subject: [PATCH 09/10] test(dogfood,rest): re-derive the authz ledger figures, the probe blind-spot census and the query-slot floor after the picker route's deletion The matrix docblock's rest ledger figure 83 -> 82 rows (18 families). The blind-spot census: rest-route-ledger 83/83/0 -> 82/82/0, rest-server 72/19/53 -> 71/19/52 (the picker route was a blind spot, outside registerMetadataEndpoints), totals 67/72 -> 66/71. The canonical-query-AST floor for rest-server.ts 5 -> 4 query slots. Every figure re-measured. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- .../dogfood/test/authz-conformance.matrix.ts | 2 +- .../test/authz-probe-blind-spot.census.ts | 44 ++++++++++++++----- .../rest-server-canonical-query-ast.test.ts | 7 ++- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 1e811c48072..b27963f37ca 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -24,7 +24,7 @@ // hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17 // dispatcher domain files. // -// The population comes from `packages/rest/src/rest-route-ledger.ts` (83 rows +// The population comes from `packages/rest/src/rest-route-ledger.ts` (82 rows // / 18 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index f5c5024dd8b..a5059fb86e4 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -101,7 +101,7 @@ // conclusion that gets re-derived from scratch otherwise: // // WHAT THE LEDGERS DO COVER — richly, and more than this table ever has. -// `packages/rest/src/rest-route-ledger.ts`: 83 audited rows over 18 families, +// `packages/rest/src/rest-route-ledger.ts`: 82 audited rows over 18 families, // every route `@objectstack/rest` mounts, enumerated through // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; @@ -332,18 +332,25 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ kinds: ['ROUTE_ENUMERATION'], probes: 1, keys: 18, - population: 83, - reachable: 83, + // [#21180] 83 / 83 / 0 -> 82 / 82 / 0: the anonymous lookup-picker row + // (`GET /api/v1/forms/:slug/lookup/:field`) left the ledger with its route. + // It carried `family: 'forms'`, a family the two surviving form rows still + // carry, so `reachable` moves with `population`, the blind spot stays 0 and + // `keys` stays 18 (18 distinct families before and after, re-derived). + population: 82, + reachable: 82, blindSpot: 0, populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)', - controls: { "route: '": 83, "family: '": 83, RestRouteLedgerEntry: 2 }, + controls: { "route: '": 82, "family: '": 82, RestRouteLedgerEntry: 2 }, note: 'The audited disposition of every route @objectstack/rest mounts, enumerated through ' + 'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' + 'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' + 'row here is already RED in another package, so a new family cannot be silently absent from this file, ' + 'and therefore cannot be silently absent from the authz ratchet either. 18 families; 1 classified by a ' + - 'matrix row (metadata), 17 enumerated in the shrink-only baseline. Re-measured 91 -> 83 (19 -> 18 families) ' + + 'matrix row (metadata), 17 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' + + 'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' + + 'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' + 'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' + '`reachable` moved with `population`. Earlier re-measured 94 -> 91 when the ' + 'three REST package read/delete rows (GET /packages, GET /packages/:id, DELETE /packages/:id) left the ' + @@ -392,9 +399,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ kinds: ['ROUTE_ENUMERATION', 'TRIPWIRE'], probes: 3, keys: 1, - population: 72, + population: 71, reachable: 19, - blindSpot: 53, + blindSpot: 52, populationRule: 'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' + '`registerPerItemRoute` (the shared forwarder, not a route; its extent is bounded by the declaration\'s own ' + @@ -482,9 +489,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // registrar's comments did not name the term). None of the eight was // inside `registerMetadataEndpoints`, so `reachable` does not move and the // blind spot shrinks by exactly the eight routes that no longer exist. + // [#21180] 72 / 19 / 53 -> 71 / 19 / 52: the anonymous lookup-picker route + // (`GET /forms/:slug/lookup/:field`) was deleted with its handler — one + // direct `this.routeManager.register(` site (65 -> 64) inside + // `registerFormEndpoints`, NOT inside `registerMetadataEndpoints`, so it + // was a BLIND SPOT: `reachable` does not move and the blind spot shrinks by + // exactly that one route. Measured on the tree before the deletion and + // after it: 72 / 19 / 53 there, 71 / 19 / 52 here. The other controls did + // not move: the handler never called `enforceAuth` (the public-form routes + // bypass it), so that stays 56, and `registerFormEndpoints` survives, so + // `private register*Endpoints(` stays 16. controls: { 'private register*Endpoints(': 16, - 'this.routeManager.register(': 65, + 'this.routeManager.register(': 64, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -497,7 +514,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + 'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, ' + - 'registerSharingRuleEndpoints, registerUiEndpoints and the rest. A runtime mount census reads 77/19/58. ' + + 'registerSharingRuleEndpoints, registerUiEndpoints and the rest. A runtime mount census reads 76/19/57. ' + 'registerUiEndpoints is NOT special — it is simply the registrar a census happened to walk past.', }, { @@ -679,7 +696,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ /** * Entry points inside the probe table's OWN files that no mintable key can * reach, counting only the route/handler surfaces the ratchet's completeness - * claim is about: rest-server.ts (53 static / 58 runtime), http-dispatcher.ts + * claim is about: rest-server.ts (52 static / 57 runtime), http-dispatcher.ts * (13) and domains/mcp.ts (1). * * hono-plugin.ts's 6 mounts are deliberately EXCLUDED from this total and @@ -688,8 +705,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ * not the six. */ // [#20102] 75 / 80 -> 67 / 72: the retired saved-report routes left rest-server.ts. -export const BLIND_SPOT_TOTAL_STATIC = 67; -export const BLIND_SPOT_TOTAL_RUNTIME = 72; +// [#21180] 67 / 72 -> 66 / 71: the retired anonymous lookup-picker route left +// rest-server.ts as one blind-spot call site and one runtime mount (measured: +// `RestServer.getRoutes()` against a bare protocol, 73 mounts -> 72). +export const BLIND_SPOT_TOTAL_STATIC = 66; +export const BLIND_SPOT_TOTAL_RUNTIME = 71; /** * Re-measure every row above from the same sources the probes read. diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index f46145599ee..081a5a79e22 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -112,7 +112,12 @@ interface CensusEntry { } const CENSUS: CensusEntry[] = [ - { file: 'rest-server.ts', source: REST_SERVER, minQuerySlots: 5, noDoor: false }, + // [#21180] 5 -> 4: the anonymous lookup picker's `pickerRequest` literal + // left with its route. Re-derived with `querySlots` itself: 5 slots on the + // tree before the deletion, 4 after (`req.query`, the import-job read and + // the two remaining server-built literals). The floor equals the measured + // count, so a slot that stops matching reds here as before. + { file: 'rest-server.ts', source: REST_SERVER, minQuerySlots: 4, noDoor: false }, { file: 'import-runner.ts', source: IMPORT_RUNNER, minQuerySlots: 3, noDoor: true }, ]; From 89bfe194274678c602899e5e5fa4efdd383720e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:14:46 +0000 Subject: [PATCH 10/10] =?UTF-8?q?chore(spec):=20regenerate=20the=20authora?= =?UTF-8?q?ble=20surface=20from=20the=20merged=20tree=20=E2=80=94=20main's?= =?UTF-8?q?=20new=20key=20on=20top=20of=20the=20retirement's=20deliberate?= =?UTF-8?q?=20deletions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index e2011667fd0..8d1f663a18a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -39,6 +39,7 @@ "ui/Action:opensInNewTab", "ui/Action:operation", "ui/Action:order", + "ui/Action:outcomeMessages", "ui/Action:params", "ui/Action:patch", "ui/Action:recordIdField",