From 73d2a78faf7e0c205097dbdbc766de2934049434 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:27:10 +0000 Subject: [PATCH 1/7] refactor(rest): move registerFormEndpoints' body into form-routes.ts (#23050 slice 11) The body of RestServer#registerFormEndpoints (GET {basePath}/forms/:slug and POST {basePath}/forms/:slug/submit) moves, re-indented one level, to one new module, packages/rest/src/form-routes.ts. The class keeps the private method, its docblock and its position as a FormRouteHost literal plus .call. Two tokens change: the two RestServer.translateOptionsFor(...) calls become this.translateOptionsFor(...) through the host, because a private static is not reachable by name outside the class. Body-only imports move with the body. Readers are re-pointed in the next commit. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/form-routes.ts | 554 +++++++++++++++++++++++++++++++ packages/rest/src/rest-server.ts | 472 ++------------------------ 2 files changed, 579 insertions(+), 447 deletions(-) create mode 100644 packages/rest/src/form-routes.ts diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts new file mode 100644 index 00000000000..464bbaa4d12 --- /dev/null +++ b/packages/rest/src/form-routes.ts @@ -0,0 +1,554 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 11] The public (anonymous) form routes of `RestServer` — + * `GET {basePath}/forms/:slug`, which resolves a form its author opened to + * anonymous intake, and `POST {basePath}/forms/:slug/submit`, which inserts a + * record through it — moved out of `rest-server.ts` whole. `registerRoutes` + * calls the registrar once for each base it mounts, under no switch of its + * own, and before the greedy `/data/:object` matcher. + * + * A MOVE, not a redesign. The function below is the body of + * `RestServer#registerFormEndpoints` as it stood, re-indented one level: the + * same two routes, registered in the same order, by the same handlers, + * answering the same bodies, with the one form resolution + * (`resolveFormBySlug`) still shared by both doors. Which forms are open is + * still the one rule `@objectstack/metadata-core` owns, imported here under the + * same names. + * + * ## How the moved body reaches the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps the private method — + * the published `dist/*.d.ts` lists its name, docblock and position — and the + * body is one host literal ({@link FormRouteHost}) plus `.call(host, basePath)`. + * A method is handed over as a closure that calls it on the server at call + * time, so a test double or spy set on the server is still the one the body + * reaches. + * + * Each member was checked on its own, not inherited from an earlier slice: + * + * - `routeManager` goes BY VALUE. The body reads it only for its two + * `register` calls, which run while registration runs, and the class's one + * `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never + * this registrar. + * - No member goes as a getter, because the body reads no FIELD at request + * time. The protocol, the tenancy service, the object definitions, the i18n + * service, its bundle and locale, and the packaged base are each resolved + * per request by a METHOD of the class, and every method goes as a closure. + * No provider field is read either, so the provider ruling has nothing to + * apply to here. + * + * ## The two tokens that are not byte-identical + * + * The resolve door translates the served object schema and the form's section + * headings with options from the class's PRIVATE STATIC `translateOptionsFor`, + * which it used to call as `RestServer.translateOptionsFor(…)`, twice. A + * private static is reachable by name only inside the class, so that spelling + * does not compile here, and making the member public, or moving it, changes + * the published declaration the move must keep byte-identical. So both calls + * reach it the way every other class member this body reads is reached: + * through the host, as `this.translateOptionsFor(…)`, a closure on + * {@link FormRouteHost} that calls `RestServer.translateOptionsFor` with the + * same two arguments. The static reads nothing but its arguments, so the + * receiver is the whole difference, as it was for slice 7's + * `overlayDocumentationInfo`. Those two receivers are the only tokens of the + * body that changed. + * + * Module-level names stay where they are declared. The meta doors' request + * envelope `TransportScopedMetaRequest` is imported from `ui-routes.ts`, where + * slice 10 moved it, and the data doors' `ServerScopedDataRequest` from + * `data-action-routes.ts`, where slice 1 moved it. `anonymousFormIntakeWithdrawnIn`, + * `PUBLIC_FORM_SERVER_MANAGED_FIELDS` and `isExpectedRouteError`, which only + * this body read, are imported here instead of there. No export was added + * anywhere, and the published surface does not move. + */ + +import { + anonymousFormIntakeCandidates, + anonymousFormIntakePosture, + anonymousFormIntakeUnavailability, + anonymousFormIntakeWithdrawnIn, + anonymousFormObjectName, +} from '@objectstack/metadata-core'; +import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; +import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import type { ServerScopedDataRequest } from './data-action-routes.js'; +import { isExpectedRouteError, mapDataError } from './error-response.js'; +import { logError } from './log.js'; +import type { RestServer } from './rest-server.js'; +import type { TransportScopedMetaRequest } from './ui-routes.js'; + +/** + * The `RestServer` members {@link registerFormEndpoints} reads — every one of + * them, and no other, in the order the body first reads them. Each is typed AS + * the class member (an indexed access on the class), so a change to a member's + * signature on the class is a change here too, and naming a member the class + * does not have is a compile error. `translateOptionsFor` is the class's + * private STATIC, typed as the static it is. The module header says why the + * body reaches it through the host, and why none of the members is a getter. + */ +export interface FormRouteHost { + resolveProtocol: RestServer['resolveProtocol']; + resolveAnonymousFormTenancy: RestServer['resolveAnonymousFormTenancy']; + readFormObjectDefinitions: RestServer['readFormObjectDefinitions']; + readonly routeManager: RestServer['routeManager']; + resolveI18nService: RestServer['resolveI18nService']; + buildTranslationBundle: RestServer['buildTranslationBundle']; + extractLocale: RestServer['extractLocale']; + translateOptionsFor: (typeof RestServer)['translateOptionsFor']; + packagedObjectBase: RestServer['packagedObjectBase']; +} + +/** + * Register the public (anonymous) form endpoints — + * `RestServer#registerFormEndpoints`' body, called by that method with the + * server as `this`. The method's own docblock, which the published declaration + * carries, names the two routes and what authorizes them. + */ +export function registerFormEndpoints(this: FormRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + + // Which form candidates are open to anonymous intake is ONE rule, + // shared with the write-time judgement in `@objectstack/metadata-protocol` + // (`anonymousFormIntakeCandidates`): `sharing.enabled === true`, + // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. + // + // A withdrawal is a kill switch: layering may only narrow anonymous + // intake, never re-open it. The doors read one list, the + // environment's (environment → code), which holds one item per + // package of a view name, and judge every candidate against that same + // list as the layer beneath it: a candidate is served only when no + // body of its view name explicitly withdraws the same form + // (`anonymousFormIntakeWithdrawnIn`: matched by slot or by slug, the + // link kept with a switch set to `false`), so a withdrawal in one + // package closes the name's form in every package (a known limit that + // fails closed). Another view publishing the same slug is a different + // form and closes nothing. + const findPublicFormView = ( + views: any[], + slug: string, + ): { view: any; form: any; object: string } | null => { + for (const view of views ?? []) { + if (!view || typeof view !== 'object') continue; + for (const c of anonymousFormIntakeCandidates(view)) { + if (c.slug !== slug) continue; + if (anonymousFormIntakeWithdrawnIn(views, view, c)) continue; + const objectName = anonymousFormObjectName(view, c.form); + if (!objectName) continue; + return { view, form: c.form, object: objectName }; + } + } + return null; + }; + + // [ADR-0131 D6, C7] WHICH metadata an anonymous form request reads: + // the environment's, like every other metadata read. Until v18 the + // `view` read also kept the Default Organization's legacy layer, so an + // organization's withdrawal still closed the form. The v18 ceremony + // (`os migrate organization-ownership --apply`, its metadata-promotion + // step) carries every organization-layer withdrawal onto the + // environment, fail-closed (rulings A on #22008 and #22011), and that + // read is deleted with it. + // + // The tenancy service is still read: it answers the posture the + // intake-availability rule judges (#21476). + // + // Asked ONCE per request, in `resolveFormBySlug`. Every door below + // reads the form through that one resolution, so no door keeps its + // own copy of "is this form public" — nor, since #21476, of "can it + // take intake on this posture", which the same tenancy read answers. + // + // Fails CLOSED. A tenancy service that is registered but cannot be + // reached raises `AuthzStoreUnavailableError`, the classification + // `classifyAdmissionTenancyPosture` applies to the same seam. The + // door then refuses instead of judging intake without a posture. + // Only the registry's own "never registered" brand reads as the + // supported no-tenancy composition. The wiring mirrors + // `resolveProtocol`, so the tenancy service and the protocol always + // come from the same kernel. + const resolveFormBySlug = async ( + environmentId: string | undefined, + req: any, + slug: string, + ): Promise<{ view: any; form: any; object: string } | null> => { + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).getMetaItems !== 'function') return null; + const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); + const viewsRequest: TransportScopedMetaRequest = { + type: 'view', + ...(environmentId ? { environmentId } : {}), + }; + const listOf = (result: any): any[] => (Array.isArray(result?.items) + ? result.items + : Array.isArray(result) + ? result + : []); + const items = listOf(await p.getMetaItems(viewsRequest)); + const match = findPublicFormView(items, slug); + if (!match) return null; + // [#21476] A form that cannot take intake on this posture is not + // offered: `null` here IS the withdrawn form's answer on both + // doors, so an anonymous caller learns nothing about the tenancy. + const unavailable = await anonymousFormIntakeUnavailability( + match.object, + anonymousFormIntakePosture(tenancy), + async () => (await this.readFormObjectDefinitions(p, environmentId)) + .find((o: any) => o?.name === match.object), + ); + return unavailable ? null : match; + }; + + // GET /forms/:slug — resolve and return the public form spec + this.routeManager.register({ + method: 'GET', + path: `${basePath}/forms/:slug`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const slug = String(req.params?.slug ?? '').trim(); + if (!slug) { + res.status(400).json({ code: 'INVALID_REQUEST', error: 'slug is 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; + } + // Embed the target object's schema — limited to exactly the + // fields the form's sections DECLARE — so anonymous + // front-ends can render the form without a separate, + // auth-protected meta lookup. + // + // [#6601] "Exactly" is load-bearing and used not to be. The + // narrowing read `allowed.size === 0 || allowed.has(name)`, + // so a form with no sections (or sections declaring no + // fields) fell through to EVERY non-server-managed field of + // the object — published to an ANONYMOUS caller, with + // labels, types, picklist option values and formula + // expressions. A form created before its sections are wired + // is an ordinary authoring mid-state, so that was reachable + // without an exotic configuration, and this comment claimed + // the opposite. Publication is a DECLARATION now: declare no + // fields and nothing is published (AGENTS.md "Explicit + // composition over default magic"). + // + // Do NOT reach for the submit handler as the backstop here. + // It enforces a field whitelist on WRITES, which cannot + // bound a READ disclosure — and when #6601 landed, its own + // accepted set still degenerated identically for a + // section-less form (`allowedFields.size === 0 ||`), so + // narrowing this schema to "what submit would accept" would + // have republished precisely the set being removed. #6920 + // has since closed that write-side twin, so the two planes + // now agree — but they agree by each enforcing the + // declaration itself, NOT by one deferring to the other. + let objectSchema: any = null; + // [#23497] The form served below: the matched form, with its + // sections' `label` / `description` localized beside the + // schema's field labels (see the translation block). + let servedForm: any = match.form; + try { + const p = await this.resolveProtocol(environmentId, req); + if (typeof (p as any).getMetaItems === 'function') { + // [#21331] The environment's object schema, as + // every object read is (ADR-0131 D6). + 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); + if (obj && obj.fields && typeof obj.fields === 'object') { + const allowed = new Set(); + for (const sec of match.form?.sections ?? []) { + for (const f of sec?.fields ?? []) { + if (typeof f === 'string') allowed.add(f); + else if (f?.field) allowed.add(f.field); + } + } + const fields: Record = {}; + for (const [name, def] of Object.entries(obj.fields)) { + // [#3022] Server-managed anchors are never + // renderable/writable on the anonymous form + // surface — the submit route refuses them, so + // don't advertise them here even when a form + // (mis)declares one in a section. + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue; + // [#6601] Declared or not published. An empty + // `allowed` yields an empty `fields`. + if (!allowed.has(name)) continue; + fields[name] = def; + } + objectSchema = { name: obj.name, label: obj.label, fields }; + // Localize labels / help text / option labels so anonymous + // clients render in the visitor's preferred language. + // `resolveFormBySlug` returns the raw view spec, so both + // halves of the payload are hydrated here: the schema, and + // the form's section headings ([#23497], below). + try { + const i18n = await this.resolveI18nService(environmentId, req); + const bundle = this.buildTranslationBundle(i18n); + const locale = this.extractLocale(req, i18n); + if (bundle && locale) { + const { translateMetadataDocument } = await import('@objectstack/spec/system'); + // [#8284] Same rule as the two `/meta/object` + // reads: the catalog yields to a scalar the + // package's own extension or the tenant + // authored. The public form must not be the + // one surface still serving the packaged + // string back at a tenant who renamed it. + objectSchema = translateMetadataDocument('object', objectSchema, bundle, { + ...this.translateOptionsFor(i18n, locale), + packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name), + }); + // [#23497] The section headings. An anonymous + // visitor cannot load the bundle client-side (the + // `/i18n` domain answers it 401), so a section's + // `objects.OBJECT._sections.NAME` copy reaches it + // here or not at all. ONE call into the spec's view + // translator, which owns every rule: the key is the + // section's `name` (a nameless section is returned + // as authored), a missing key keeps the authored + // text, only authored copy is translated, and an + // explicit override beats the catalog against the + // packaged view, handed over exactly as the object + // call above hands its own base. ⛔ No walk of + // `_sections` and no locale chain here. + // + // The protocol's view list serves view ITEMS only + // (a container is dropped and served as the + // `viewKind: 'form'` items it expands to), so the + // matched form is the item's `config`, the address + // `translateView` translates sections at. A form + // matched in any other slot is served as matched, + // never swapped for the item's `config`. It runs + // BEFORE the strip below and returns each section's + // `fields` untouched, so it cannot re-introduce a + // stripped field. The top-level `label` keeps its + // own reading: the translator falls back to the + // view's machine name where this route falls back + // to the form's label. + const view = translateMetadataDocument('view', match.view, bundle, { + ...this.translateOptionsFor(i18n, locale), + packagedBase: this.packagedObjectBase(p, 'view', match.view?.name), + }); + if (match.form === match.view?.config) servedForm = view?.config; + } + } catch (e: any) { + logError('[REST] Public form schema translation failed:', e); + } + } + } + } catch (e: any) { + logError('[REST] Public form schema load failed:', e); + } + // 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 (!servedForm || !Array.isArray(servedForm.sections)) return servedForm; + 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 + // never collects a value the submit route refuses. + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) return false; + const def = objectSchema?.fields?.[name]; + 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. + return t !== 'lookup' && t !== 'master_detail' && t !== 'user'; + }; + const sections = servedForm.sections.map((sec: any) => { + const fields = (sec?.fields ?? []).filter((f: any) => { + const name = typeof f === 'string' ? f : f?.field; + if (!name) return false; + return allow(name); + }); + return { ...sec, fields }; + }); + return { ...servedForm, sections }; + })(); + res.header('Vary', 'Accept-Language'); + res.json({ + slug, + object: match.object, + label: match.view?.label ?? match.form?.label, + form: safeForm, + objectSchema, + }); + } catch (error: any) { + logError('[REST] Public form resolve error:', error); + res.status(500).json({ + code: 'FORM_RESOLVE_FAILED', + error: String(error?.message ?? error ?? 'resolve failed').slice(0, 500), + }); + } + }, + metadata: { + summary: 'Resolve a public form spec by slug (anonymous)', + tags: ['forms', 'public'], + }, + }); + + // POST /forms/:slug/submit — INSERT a record on the target object + // with the `guest_portal` permission set attached. + this.routeManager.register({ + method: 'POST', + path: `${basePath}/forms/:slug/submit`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const slug = String(req.params?.slug ?? '').trim(); + if (!slug) { + res.status(400).json({ code: 'INVALID_REQUEST', error: 'slug is 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; + } + + // Only allow the fields declared on the matched FormView. + // This prevents a public visitor from stuffing privileged + // columns (owner_id, status, internal_notes, …) into the + // row. Object hooks (`beforeInsert`) are still responsible + // for stamping server-side defaults — see the CRM + // `lead.hook.ts` / `case.hook.ts` for the canonical pattern. + const allowedFields = new Set(); + for (const section of match.form?.sections ?? []) { + for (const f of section?.fields ?? []) { + if (typeof f === 'string') allowedFields.add(f); + else if (f?.field) allowedFields.add(f.field); + } + } + // [#6920] A form that declares NO fields collects nothing, so + // it has nothing to accept — refuse instead of inserting. + // + // The filter below used to read + // `allowedFields.size === 0 || allowedFields.has(k)`, and that + // fall-through was not "accept every field of the object": it + // accepted every KEY THE CALLER SENT, minus the anchors and the + // three prototype keys — measured as + // `["email","internal_margin","internal_tier","not_even_a_field", + // "status","subject"]` on a `sections: []` form, `not_even_a_field` + // not being a field of the object at all. On an ANONYMOUS surface + // that is unbounded mass assignment across the target object, and + // the form created-before-its-sections-are-wired mid-state reaches + // it without anything exotic. + // + // Symmetric with #6601 on the read side of the same pair: declare + // it or it is not published / not accepted, one rule on both planes + // (AGENTS.md "Explicit composition over default magic"). Post-#6601 + // such a form publishes `fields: {}`, so no legitimate client can + // even learn what to send here. + // + // REFUSAL, not a silent discard: dropping the keys would leave the + // `201` intact while swallowing data the caller believes it wrote — + // exactly the silence AGENTS.md's warn-vs-error rule forbids. The + // author's fix is to wire the sections, so the message says that; + // the code is the standard ADR-0112 catalog's generic 400 + // (`HttpStatusErrorCodeMap[400]`), not a minted synonym, and the + // message names no object, field or slug — this reply is readable + // by anyone on the internet. + // + // NOTE the #3022 pin ('zero declared sections: business fields fall + // through, anchors do NOT') asserted this fall-through as intended. + // Re-judged by maintainer ruling 5229989845 (2026-08-09): the + // anchor half is preserved and still pinned; the fall-through half + // was the wrong invariant. + if (allowedFields.size === 0) { + res.status(400).json({ + code: 'VALIDATION_ERROR', + error: 'This form declares no fields, so it cannot accept a submission. ' + + "Wire the fields it collects into the form's sections and publish it again.", + }); + return; + } + // [#3022] System-managed anchors (owner_id, organization_id, + // audit columns, id, …) are NEVER client-suppliable on this + // anonymous surface, even when a FormView explicitly declares + // one in a section (the insert-forge of #3004, with no + // credentials at all). The SecurityPlugin's publicFormGrant + // branch strips the same set at the data layer, so this filter + // and the engine boundary cannot drift. + const rawBody = (req.body && typeof req.body === 'object') ? req.body : {}; + const filteredData: Record = {}; + for (const [k, v] of Object.entries(rawBody)) { + if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue; + // JSON.parse yields `__proto__` as an OWN key; assigning it + // here would REPLACE filteredData's prototype and smuggle + // inherited anchors past every own-property check downstream. + if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; + if (allowedFields.has(k)) filteredData[k] = v; + } + + // ADR-0056 (Option A): authorization DERIVED from the declared + // form — a narrow create grant scoped to exactly this form's target + // object. The SecurityPlugin honors `publicFormGrant` (create + the + // immediate read-back, that object ONLY), so public forms work under + // secure-by-default (anonymous-deny) WITHOUT a deployment-configured + // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat + // with object hooks (guest detection via falsy `ctx.user?.id`). + const context: any = { + publicFormGrant: { object: match.object }, + permissions: ['guest_portal'], + anonymous: true, + }; + + const p = await this.resolveProtocol(environmentId, req); + const formCreateRequest: ServerScopedDataRequest = { + object: match.object, + data: filteredData, + ...(environmentId ? { environmentId } : {}), + context, + }; + const result = await p.createData(formCreateRequest); + // [#22437] The answer is the created record's id, and + // nothing the insert stored. `createData` answers + // `{ object, id, record, droppedFields? }`, and its `record` + // is the row as stored AFTER the insert pipeline: defaults, + // and whatever a `beforeInsert` / `afterInsert` hook stamped + // — including a value an elevated (`runAs: 'system'`) hook + // derived from existing records this anonymous caller's + // grant may never read. The whitelist above filters what the + // caller WRITES; this line is what filters what it is SHOWN. + // The caller already knows what it submitted, so the one + // fact it lacks is the id. Projecting the row to the form's + // declared fields would not do: a hook may rewrite a + // declared field too. The key stays the top-level `id` of a + // bare body — where the console's public form page reads + // the created id — and no second read builds this answer. + res.status(201).json({ id: result.id }); + } 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 submit error:', error); + } + res.status(mapped.status).json(mapped.body); + } + }, + metadata: { + summary: 'Submit an anonymous public form', + tags: ['forms', 'public'], + }, + }); +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b429a90b19d..7958e3cf728 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -78,8 +78,9 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, - // …and whether another body of the same view name withdraws a candidate. - anonymousFormIntakeWithdrawnIn, + // `anonymousFormIntakeWithdrawnIn` (whether another body of the same view name withdraws a + // candidate), read only by the anonymous form doors, moved with `registerFormEndpoints`' + // body to `form-routes.ts` (#23050 slice 11). // [#21476] Whether such a form can take intake on this posture, and why not // — the one predicate the runtime authoring gate's advisory reads too. anonymousFormIntakePosture, @@ -171,7 +172,8 @@ import type { // single-item read path rebuilds its body through, from the spec's own storage // predicates — so the signal the grid reads cannot drift from what the runtime // doors (#6994/#7095) refuse. -import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +// `PUBLIC_FORM_SERVER_MANAGED_FIELDS`, read only by the anonymous form doors, moved with +// `registerFormEndpoints`' body to `form-routes.ts` (#23050 slice 11). import { PLURAL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; // `stripReadDecorations`, read only by the analytics dataset door, moved with that door to // `analytics-routes.ts` (#23050 slice 5). @@ -310,6 +312,8 @@ import { registerEmailEndpoints, type EmailRouteHost } from './email-routes.js'; import { registerSearchEndpoints, type SearchRouteHost } from './search-routes.js'; // [#23050 slice 10] The UI view route's body moved to its own module — see its header. import { registerUiEndpoints, type TransportScopedMetaRequest, type UiRouteHost } from './ui-routes.js'; +// [#23050 slice 11] The public form routes' body moved to its own module — see its header. +import { registerFormEndpoints, type FormRouteHost } from './form-routes.js'; import { loadExcelJs, type Worksheet } from './xlsx-module.js'; // [#18386] `?template=true` on the export door: the import template's column // rule, request reading and workbook. See the module header. @@ -352,7 +356,8 @@ import { handleRouteError, thrownAnswerIsBareNotFound, logUnexpectedRouteError, - isExpectedRouteError, + // `isExpectedRouteError`, read only by the public form submit door's catch, moved with + // `registerFormEndpoints`' body to `form-routes.ts` (#23050 slice 11). applyDroppedFieldsHeader, } from './error-response.js'; export { mapDataError }; @@ -9081,450 +9086,23 @@ export class RestServer { * `mapViewSpecToEmbeddableConfig` expects. */ private registerFormEndpoints(basePath: string): void { - const isScoped = basePath.includes('/environments/:environmentId'); - - // Which form candidates are open to anonymous intake is ONE rule, - // shared with the write-time judgement in `@objectstack/metadata-protocol` - // (`anonymousFormIntakeCandidates`): `sharing.enabled === true`, - // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. - // - // A withdrawal is a kill switch: layering may only narrow anonymous - // intake, never re-open it. The doors read one list, the - // environment's (environment → code), which holds one item per - // package of a view name, and judge every candidate against that same - // list as the layer beneath it: a candidate is served only when no - // body of its view name explicitly withdraws the same form - // (`anonymousFormIntakeWithdrawnIn`: matched by slot or by slug, the - // link kept with a switch set to `false`), so a withdrawal in one - // package closes the name's form in every package (a known limit that - // fails closed). Another view publishing the same slug is a different - // form and closes nothing. - const findPublicFormView = ( - views: any[], - slug: string, - ): { view: any; form: any; object: string } | null => { - for (const view of views ?? []) { - if (!view || typeof view !== 'object') continue; - for (const c of anonymousFormIntakeCandidates(view)) { - if (c.slug !== slug) continue; - if (anonymousFormIntakeWithdrawnIn(views, view, c)) continue; - const objectName = anonymousFormObjectName(view, c.form); - if (!objectName) continue; - return { view, form: c.form, object: objectName }; - } - } - return null; - }; - - // [ADR-0131 D6, C7] WHICH metadata an anonymous form request reads: - // the environment's, like every other metadata read. Until v18 the - // `view` read also kept the Default Organization's legacy layer, so an - // organization's withdrawal still closed the form. The v18 ceremony - // (`os migrate organization-ownership --apply`, its metadata-promotion - // step) carries every organization-layer withdrawal onto the - // environment, fail-closed (rulings A on #22008 and #22011), and that - // read is deleted with it. - // - // The tenancy service is still read: it answers the posture the - // intake-availability rule judges (#21476). - // - // Asked ONCE per request, in `resolveFormBySlug`. Every door below - // reads the form through that one resolution, so no door keeps its - // own copy of "is this form public" — nor, since #21476, of "can it - // take intake on this posture", which the same tenancy read answers. - // - // Fails CLOSED. A tenancy service that is registered but cannot be - // reached raises `AuthzStoreUnavailableError`, the classification - // `classifyAdmissionTenancyPosture` applies to the same seam. The - // door then refuses instead of judging intake without a posture. - // Only the registry's own "never registered" brand reads as the - // supported no-tenancy composition. The wiring mirrors - // `resolveProtocol`, so the tenancy service and the protocol always - // come from the same kernel. - const resolveFormBySlug = async ( - environmentId: string | undefined, - req: any, - slug: string, - ): Promise<{ view: any; form: any; object: string } | null> => { - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).getMetaItems !== 'function') return null; - const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); - const viewsRequest: TransportScopedMetaRequest = { - type: 'view', - ...(environmentId ? { environmentId } : {}), - }; - const listOf = (result: any): any[] => (Array.isArray(result?.items) - ? result.items - : Array.isArray(result) - ? result - : []); - const items = listOf(await p.getMetaItems(viewsRequest)); - const match = findPublicFormView(items, slug); - if (!match) return null; - // [#21476] A form that cannot take intake on this posture is not - // offered: `null` here IS the withdrawn form's answer on both - // doors, so an anonymous caller learns nothing about the tenancy. - const unavailable = await anonymousFormIntakeUnavailability( - match.object, - anonymousFormIntakePosture(tenancy), - async () => (await this.readFormObjectDefinitions(p, environmentId)) - .find((o: any) => o?.name === match.object), - ); - return unavailable ? null : match; + // [#23050 slice 11] The body moved to `form-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `FormRouteHost`, and that module's header for why no + // member is a getter and why the private static `translateOptionsFor` + // is handed over too). + const host: FormRouteHost = { + resolveProtocol: (...a) => this.resolveProtocol(...a), + resolveAnonymousFormTenancy: (...a) => this.resolveAnonymousFormTenancy(...a), + readFormObjectDefinitions: (...a) => this.readFormObjectDefinitions(...a), + routeManager: this.routeManager, + resolveI18nService: (...a) => this.resolveI18nService(...a), + buildTranslationBundle: (...a) => this.buildTranslationBundle(...a), + extractLocale: (...a) => this.extractLocale(...a), + translateOptionsFor: (...a) => RestServer.translateOptionsFor(...a), + packagedObjectBase: (...a) => this.packagedObjectBase(...a), }; - - // GET /forms/:slug — resolve and return the public form spec - this.routeManager.register({ - method: 'GET', - path: `${basePath}/forms/:slug`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const slug = String(req.params?.slug ?? '').trim(); - if (!slug) { - res.status(400).json({ code: 'INVALID_REQUEST', error: 'slug is 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; - } - // Embed the target object's schema — limited to exactly the - // fields the form's sections DECLARE — so anonymous - // front-ends can render the form without a separate, - // auth-protected meta lookup. - // - // [#6601] "Exactly" is load-bearing and used not to be. The - // narrowing read `allowed.size === 0 || allowed.has(name)`, - // so a form with no sections (or sections declaring no - // fields) fell through to EVERY non-server-managed field of - // the object — published to an ANONYMOUS caller, with - // labels, types, picklist option values and formula - // expressions. A form created before its sections are wired - // is an ordinary authoring mid-state, so that was reachable - // without an exotic configuration, and this comment claimed - // the opposite. Publication is a DECLARATION now: declare no - // fields and nothing is published (AGENTS.md "Explicit - // composition over default magic"). - // - // Do NOT reach for the submit handler as the backstop here. - // It enforces a field whitelist on WRITES, which cannot - // bound a READ disclosure — and when #6601 landed, its own - // accepted set still degenerated identically for a - // section-less form (`allowedFields.size === 0 ||`), so - // narrowing this schema to "what submit would accept" would - // have republished precisely the set being removed. #6920 - // has since closed that write-side twin, so the two planes - // now agree — but they agree by each enforcing the - // declaration itself, NOT by one deferring to the other. - let objectSchema: any = null; - // [#23497] The form served below: the matched form, with its - // sections' `label` / `description` localized beside the - // schema's field labels (see the translation block). - let servedForm: any = match.form; - try { - const p = await this.resolveProtocol(environmentId, req); - if (typeof (p as any).getMetaItems === 'function') { - // [#21331] The environment's object schema, as - // every object read is (ADR-0131 D6). - 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); - if (obj && obj.fields && typeof obj.fields === 'object') { - const allowed = new Set(); - for (const sec of match.form?.sections ?? []) { - for (const f of sec?.fields ?? []) { - if (typeof f === 'string') allowed.add(f); - else if (f?.field) allowed.add(f.field); - } - } - const fields: Record = {}; - for (const [name, def] of Object.entries(obj.fields)) { - // [#3022] Server-managed anchors are never - // renderable/writable on the anonymous form - // surface — the submit route refuses them, so - // don't advertise them here even when a form - // (mis)declares one in a section. - if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) continue; - // [#6601] Declared or not published. An empty - // `allowed` yields an empty `fields`. - if (!allowed.has(name)) continue; - fields[name] = def; - } - objectSchema = { name: obj.name, label: obj.label, fields }; - // Localize labels / help text / option labels so anonymous - // clients render in the visitor's preferred language. - // `resolveFormBySlug` returns the raw view spec, so both - // halves of the payload are hydrated here: the schema, and - // the form's section headings ([#23497], below). - try { - const i18n = await this.resolveI18nService(environmentId, req); - const bundle = this.buildTranslationBundle(i18n); - const locale = this.extractLocale(req, i18n); - if (bundle && locale) { - const { translateMetadataDocument } = await import('@objectstack/spec/system'); - // [#8284] Same rule as the two `/meta/object` - // reads: the catalog yields to a scalar the - // package's own extension or the tenant - // authored. The public form must not be the - // one surface still serving the packaged - // string back at a tenant who renamed it. - objectSchema = translateMetadataDocument('object', objectSchema, bundle, { - ...RestServer.translateOptionsFor(i18n, locale), - packagedBase: this.packagedObjectBase(p, 'object', objectSchema?.name), - }); - // [#23497] The section headings. An anonymous - // visitor cannot load the bundle client-side (the - // `/i18n` domain answers it 401), so a section's - // `objects.OBJECT._sections.NAME` copy reaches it - // here or not at all. ONE call into the spec's view - // translator, which owns every rule: the key is the - // section's `name` (a nameless section is returned - // as authored), a missing key keeps the authored - // text, only authored copy is translated, and an - // explicit override beats the catalog against the - // packaged view, handed over exactly as the object - // call above hands its own base. ⛔ No walk of - // `_sections` and no locale chain here. - // - // The protocol's view list serves view ITEMS only - // (a container is dropped and served as the - // `viewKind: 'form'` items it expands to), so the - // matched form is the item's `config`, the address - // `translateView` translates sections at. A form - // matched in any other slot is served as matched, - // never swapped for the item's `config`. It runs - // BEFORE the strip below and returns each section's - // `fields` untouched, so it cannot re-introduce a - // stripped field. The top-level `label` keeps its - // own reading: the translator falls back to the - // view's machine name where this route falls back - // to the form's label. - const view = translateMetadataDocument('view', match.view, bundle, { - ...RestServer.translateOptionsFor(i18n, locale), - packagedBase: this.packagedObjectBase(p, 'view', match.view?.name), - }); - if (match.form === match.view?.config) servedForm = view?.config; - } - } catch (e: any) { - logError('[REST] Public form schema translation failed:', e); - } - } - } - } catch (e: any) { - logError('[REST] Public form schema load failed:', e); - } - // 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 (!servedForm || !Array.isArray(servedForm.sections)) return servedForm; - 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 - // never collects a value the submit route refuses. - if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(name)) return false; - const def = objectSchema?.fields?.[name]; - 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. - return t !== 'lookup' && t !== 'master_detail' && t !== 'user'; - }; - const sections = servedForm.sections.map((sec: any) => { - const fields = (sec?.fields ?? []).filter((f: any) => { - const name = typeof f === 'string' ? f : f?.field; - if (!name) return false; - return allow(name); - }); - return { ...sec, fields }; - }); - return { ...servedForm, sections }; - })(); - res.header('Vary', 'Accept-Language'); - res.json({ - slug, - object: match.object, - label: match.view?.label ?? match.form?.label, - form: safeForm, - objectSchema, - }); - } catch (error: any) { - logError('[REST] Public form resolve error:', error); - res.status(500).json({ - code: 'FORM_RESOLVE_FAILED', - error: String(error?.message ?? error ?? 'resolve failed').slice(0, 500), - }); - } - }, - metadata: { - summary: 'Resolve a public form spec by slug (anonymous)', - tags: ['forms', 'public'], - }, - }); - - // POST /forms/:slug/submit — INSERT a record on the target object - // with the `guest_portal` permission set attached. - this.routeManager.register({ - method: 'POST', - path: `${basePath}/forms/:slug/submit`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const slug = String(req.params?.slug ?? '').trim(); - if (!slug) { - res.status(400).json({ code: 'INVALID_REQUEST', error: 'slug is 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; - } - - // Only allow the fields declared on the matched FormView. - // This prevents a public visitor from stuffing privileged - // columns (owner_id, status, internal_notes, …) into the - // row. Object hooks (`beforeInsert`) are still responsible - // for stamping server-side defaults — see the CRM - // `lead.hook.ts` / `case.hook.ts` for the canonical pattern. - const allowedFields = new Set(); - for (const section of match.form?.sections ?? []) { - for (const f of section?.fields ?? []) { - if (typeof f === 'string') allowedFields.add(f); - else if (f?.field) allowedFields.add(f.field); - } - } - // [#6920] A form that declares NO fields collects nothing, so - // it has nothing to accept — refuse instead of inserting. - // - // The filter below used to read - // `allowedFields.size === 0 || allowedFields.has(k)`, and that - // fall-through was not "accept every field of the object": it - // accepted every KEY THE CALLER SENT, minus the anchors and the - // three prototype keys — measured as - // `["email","internal_margin","internal_tier","not_even_a_field", - // "status","subject"]` on a `sections: []` form, `not_even_a_field` - // not being a field of the object at all. On an ANONYMOUS surface - // that is unbounded mass assignment across the target object, and - // the form created-before-its-sections-are-wired mid-state reaches - // it without anything exotic. - // - // Symmetric with #6601 on the read side of the same pair: declare - // it or it is not published / not accepted, one rule on both planes - // (AGENTS.md "Explicit composition over default magic"). Post-#6601 - // such a form publishes `fields: {}`, so no legitimate client can - // even learn what to send here. - // - // REFUSAL, not a silent discard: dropping the keys would leave the - // `201` intact while swallowing data the caller believes it wrote — - // exactly the silence AGENTS.md's warn-vs-error rule forbids. The - // author's fix is to wire the sections, so the message says that; - // the code is the standard ADR-0112 catalog's generic 400 - // (`HttpStatusErrorCodeMap[400]`), not a minted synonym, and the - // message names no object, field or slug — this reply is readable - // by anyone on the internet. - // - // NOTE the #3022 pin ('zero declared sections: business fields fall - // through, anchors do NOT') asserted this fall-through as intended. - // Re-judged by maintainer ruling 5229989845 (2026-08-09): the - // anchor half is preserved and still pinned; the fall-through half - // was the wrong invariant. - if (allowedFields.size === 0) { - res.status(400).json({ - code: 'VALIDATION_ERROR', - error: 'This form declares no fields, so it cannot accept a submission. ' - + "Wire the fields it collects into the form's sections and publish it again.", - }); - return; - } - // [#3022] System-managed anchors (owner_id, organization_id, - // audit columns, id, …) are NEVER client-suppliable on this - // anonymous surface, even when a FormView explicitly declares - // one in a section (the insert-forge of #3004, with no - // credentials at all). The SecurityPlugin's publicFormGrant - // branch strips the same set at the data layer, so this filter - // and the engine boundary cannot drift. - const rawBody = (req.body && typeof req.body === 'object') ? req.body : {}; - const filteredData: Record = {}; - for (const [k, v] of Object.entries(rawBody)) { - if (PUBLIC_FORM_SERVER_MANAGED_FIELDS.has(k)) continue; - // JSON.parse yields `__proto__` as an OWN key; assigning it - // here would REPLACE filteredData's prototype and smuggle - // inherited anchors past every own-property check downstream. - if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; - if (allowedFields.has(k)) filteredData[k] = v; - } - - // ADR-0056 (Option A): authorization DERIVED from the declared - // form — a narrow create grant scoped to exactly this form's target - // object. The SecurityPlugin honors `publicFormGrant` (create + the - // immediate read-back, that object ONLY), so public forms work under - // secure-by-default (anonymous-deny) WITHOUT a deployment-configured - // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat - // with object hooks (guest detection via falsy `ctx.user?.id`). - const context: any = { - publicFormGrant: { object: match.object }, - permissions: ['guest_portal'], - anonymous: true, - }; - - const p = await this.resolveProtocol(environmentId, req); - const formCreateRequest: ServerScopedDataRequest = { - object: match.object, - data: filteredData, - ...(environmentId ? { environmentId } : {}), - context, - }; - const result = await p.createData(formCreateRequest); - // [#22437] The answer is the created record's id, and - // nothing the insert stored. `createData` answers - // `{ object, id, record, droppedFields? }`, and its `record` - // is the row as stored AFTER the insert pipeline: defaults, - // and whatever a `beforeInsert` / `afterInsert` hook stamped - // — including a value an elevated (`runAs: 'system'`) hook - // derived from existing records this anonymous caller's - // grant may never read. The whitelist above filters what the - // caller WRITES; this line is what filters what it is SHOWN. - // The caller already knows what it submitted, so the one - // fact it lacks is the id. Projecting the row to the form's - // declared fields would not do: a hook may rewrite a - // declared field too. The key stays the top-level `id` of a - // bare body — where the console's public form page reads - // the created id — and no second read builds this answer. - res.status(201).json({ id: result.id }); - } 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 submit error:', error); - } - res.status(mapped.status).json(mapped.body); - } - }, - metadata: { - summary: 'Submit an anonymous public form', - tags: ['forms', 'public'], - }, - }); + registerFormEndpoints.call(host, basePath); } /** From 9c26739ec99d9a0fdc9533674ee42f7c544b2350 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:42:32 +0000 Subject: [PATCH 2/7] test(rest): the registrar list gains form-routes.ts (#23050 slice 11) REST_SERVER_REGISTRAR_FILES grows 11 -> 12, so every whole-file reader of the registrars' code reads the moved form doors too. The readers that pin the list's length re-read their numbers in the next commit. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/rest-server-registrar-files.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index b26d5af6b76..38fc966732f 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -49,6 +49,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'search-routes.ts', // [#23050 slice 10] `registerUiEndpoints`' body. 'ui-routes.ts', + // [#23050 slice 11] `registerFormEndpoints`' body. + 'form-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ From de5848dae4116ec7a17e2cd670ee7ffba07fcef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:51:36 +0000 Subject: [PATCH 3/7] test(rest): re-point every reader of the moved form doors (#23050 slice 11) Each reader whose reading included a moved line now reads it in form-routes.ts, with its sum unchanged: - the registrar-list readers re-read their numbers (length 11 -> 12): the query-number, canonical-query-AST, meta-draft read-door and execctx consumer censuses, the docblock-position pin, the two ui-view measurements and both dogfood authz readers; - check-route-envelope declares form-routes.ts at stringError 4 and siblingCode 6, and the rest-server.ts row goes 15 -> 11 and 13 -> 7 (sums 39 and 54 unchanged); - a new ADR anchor for form-routes.ts keeps check-adr-anchors' anchored decision-letter reading at 1772; - the access-security checklist source naming resolveFormBySlug points at form-routes.ts. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../areas/access-security.json | 2 +- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 28 +++++++++++---- .../rest/src/execctx-consumer-census.test.ts | 22 ++++++++++-- .../src/meta-draft-read-door-census.test.ts | 5 ++- .../rest-server-canonical-query-ast.test.ts | 9 ++++- .../src/rest-server-docblock-position.test.ts | 11 ++++-- .../rest-server-query-number-census.test.ts | 5 ++- ...ui-view-route-identity.measurement.test.ts | 5 ++- .../ui-view-route-tenancy.measurement.test.ts | 5 ++- .../packages__rest__src__form-routes.ts.json | 9 +++++ scripts/check-route-envelope.mjs | 35 +++++++++++++++++-- 12 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 scripts/adr-anchors/packages__rest__src__form-routes.ts.json diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 2036d0ec68f..3d0faa75e11 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1605,7 +1605,7 @@ "source": [ "packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn (the one judgement the doors make over the environment's list: a body of the same view name withdraws a candidate matched by slot or by slug; the package is not compared)", "packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormExplicitWithdrawals (explicit only: a sharing that keeps its publicLink and sets enabled or allowAnonymous to false; a schema-parsed artifact carries the default enabled false, which counts)", - "packages/rest/src/rest-server.ts#registerFormEndpoints (resolveFormBySlug: the environment's view list alone, judged against itself as the layer beneath each candidate; the Default Organization's legacy read was deleted with ADR-0131 C7, and the tenancy service is still read for the posture; a withdrawn form answers 404 FORM_NOT_FOUND on both doors)", + "packages/rest/src/form-routes.ts#registerFormEndpoints (resolveFormBySlug: the environment's view list alone, judged against itself as the layer beneath each candidate; the Default Organization's legacy read was deleted with ADR-0131 C7, and the tenancy service is still read for the posture; a withdrawn form answers 404 FORM_NOT_FOUND on both doors)", "packages/metadata-protocol/src/protocol.ts#getMetaItems (the environment's view list the doors read: one item per package that ships a view name, ADR-0048; a package-less copy stands in for every package with no copy of its own, packages/metadata-protocol/src/protocol.ts#servedViewExpansion)", "packages/metadata-protocol/src/protocol.ts#organizationScopedWriteRefusal (every organization-scoped write refused 403 NOT_OVERRIDABLE, ADR-0131 D6; PR #22515 deleted the intake-specific organization refusals and the row anchor revision 1 cited, and since PR #22447 the /meta doors hand the protocol no organization, so an organization-active administrator's save is environment-wide)", "content/docs/ui/public-data-collection.mdx, section '4. Withdraw a public form' (the published rules and the known limit for packages and names; when this revision was written its 'Known limit: legacy copies' paragraph still described the Default Organization read that ADR-0131 C7 deleted from the doors)", diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 77ecc3f29f5..4feb24cdaf7 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1632,7 +1632,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it. // [#23050 slice 9] Length 9 → 10 (`search-routes.ts`), still 0 over it. // [#23050 slice 10] Length 10 → 11 (`ui-routes.ts`), still 0 over it. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] Length 11 → 12 (`form-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { 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 6a8c1cf9086..7ec7789e0eb 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -521,11 +521,12 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' + '`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' + - '`search-routes.ts` and `ui-routes.ts`, the modules the bodies of `registerDataActionEndpoints`, ' + - '`registerBatchEndpoints`, `registerApprovalsEndpoints`, `registerSharingEndpoints`, ' + - '`registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, `registerSecurityExplainEndpoints`, ' + - '`registerSecurityEndpoints`, `registerDiscoveryEndpoints`, `registerOpenApiEndpoints`, ' + - '`registerEmailEndpoints`, `registerSearchEndpoints` and `registerUiEndpoints` moved to; ' + + '`search-routes.ts`, `ui-routes.ts` and `form-routes.ts`, the modules the bodies of ' + + '`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + + '`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' + + '`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' + + '`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' + + '`registerUiEndpoints` and `registerFormEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -770,10 +771,24 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // new occurrences are this slice's seam, the same two spellings once (the // host literal's forwarder, two; the host interface's member, two). No call // site was added. + // [#23050 slice 11] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerFormEndpoints`' body (two registration sites, `GET + // {base}/forms/:slug` and `POST {base}/forms/:slug/submit`, outside + // `registerMetadataEndpoints`, so in the blind spot) moved to + // `form-routes.ts`, which joins the list (length 11 -> 12) with its own + // control. `this.routeManager.register(` stays 63 over the twelve files + // (18 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1 + 1 + 1 + 2), + // `private register*Endpoints(` stays 16 (the registrar is still declared + // here), and `enforceAuth` stays 108: the two doors never call it (their + // authorization is the form's own declaration), so no call site moved, and + // this slice's host literal and interface name no such member. Measured at + // the move commit, before the list learned the module: population 68 and + // `this.routeManager.register(` 61 over eleven files, the two sites out of + // reach. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 11, + 'registrar list length': 12, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, @@ -784,6 +799,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'email-routes.ts: this.routeManager.register(': 1, 'search-routes.ts: this.routeManager.register(': 1, 'ui-routes.ts: this.routeManager.register(': 1, + 'form-routes.ts: this.routeManager.register(': 2, // 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 diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 7c4df47cd18..627c7600619 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -112,6 +112,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * * [#23050 slice 10] …and `registerUiEndpoints`' body to `ui-routes.ts`, with * its one. + * + * [#23050 slice 11] …and `registerFormEndpoints`' body to `form-routes.ts`, + * with none: the public form doors resolve no caller (their authorization is + * the form's own declaration). */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -165,9 +169,15 @@ const EXTRA_MOVED_REGISTRARS: Readonly>> = { 'discovery-routes.ts': 1, + 'form-routes.ts': 1, }; /** A stack frame in any censused file, as `::`. */ @@ -695,13 +705,21 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // mentions in). Twelve forwarders now: (11 - 1) + 3 - 1, the module // carrying one registrar that reads the seam, so neither map gains an // entry. - expect(SITE_FILES).toHaveLength(11); + // + // [#23050 slice 11] 62 sites / 125 mentions UNCHANGED — the same MOVE. + // `registerFormEndpoints`' body left for `form-routes.ts` with NO site: + // 22 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 = 62 sites. Its host + // literal and host interface name no `resolveExecCtx` member, so no + // mention moved and `rest-server.ts` stays at 60. Still twelve + // forwarders: (12 - 1) + 3 - 2, the module counted in + // `MOVED_REGISTRARS_READING_NO_EXECCTX`. + expect(SITE_FILES).toHaveLength(12); expect(SITES.length).toBe(62); expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(125); const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); expect(extraMovedRegistrars).toBe(3); const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0); - expect(registrarsReadingNoExecCtx).toBe(1); + expect(registrarsReadingNoExecCtx).toBe(2); expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar whose body reads it: one was respelt or removed, or a module moved without joining SITE_FILES') .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars - registrarsReadingNoExecCtx); }); diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 70738cf899d..6e20ba89341 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -256,7 +256,10 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0, length 9 → 10. // [#23050 slice 10] Nor does the UI view body (`ui-routes.ts`): // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0, length 10 → 11. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] Nor do the public form doors (`form-routes.ts`): they + // read the environment's view and object lists, never a draft: + // 8 = 6 + 0 + 0 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 0 + 0, length 11 → 12. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); 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 590111763d0..1069d7d8c0a 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -159,6 +159,11 @@ interface CensusEntry { * body (`ui-routes.ts`) builds no `query:` literal either — it hands * `getUiView` an `{ object, type }` request read off the path, never a * `query:` slot. + * + * [#23050 slice 11] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the + * public form doors (`form-routes.ts`) build no `query:` literal either — they + * list the environment's views and objects by `type` and insert through + * `createData`, never through a `query:` slot. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, @@ -172,6 +177,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // [#23050 slice 8] Length 8 → 9, the sum unchanged. // [#23050 slice 9] Length 9 → 10, the sum unchanged. // [#23050 slice 10] Length 10 → 11, the sum unchanged. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] Length 11 → 12, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 29c745a6de8..7367fc00e18 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -182,6 +182,11 @@ describe('rest-server.ts docblock position', () => { * the host interface's block and the function's. The UI view body held no TSDoc * block of its own, so `rest-server.ts` gives up only the alias's block * (164 → 163 there, still far above its floor). + * + * [#23050 slice 11] `form-routes.ts` carries three, as the search module does: + * the module header, the host interface's block and the function's. The public + * form body held no TSDoc block of its own, so `rest-server.ts` keeps every + * block it had (163). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, @@ -194,6 +199,7 @@ const MOVED_FILE_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -228,9 +234,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // [#23050 slice 8] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 = 37 over eight, length 8 → 9. // [#23050 slice 9] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 = 40 over nine, length 9 → 10. // [#23050 slice 10] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 = 44 over ten, length 10 → 11. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] 4 + 3 + 3 + 8 + 3 + 8 + 5 + 3 + 3 + 4 + 3 = 47 over eleven, length 11 → 12. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(44); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(47); }); }); 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 75c1ae578db..2ba702856fc 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -333,7 +333,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // [#23050 slice 10] Nor does the UI view body (`ui-routes.ts`): it reads // only the two path parameters, as strings: // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 10 → 11. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] Nor do the public form doors (`form-routes.ts`): they + // read only the slug path parameter, as a string: + // 9 = 8 + 1 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0, length 11 → 12. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index 5130f9332e9..7395376cee6 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -369,7 +369,10 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () // [#23050 slice 10] 10 → 11 (`ui-routes.ts`): 59 and 74, +1 each for // that slice's forwarder (the two call sites moved with the body and are // counted in both readings). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] 11 → 12 (`form-routes.ts`): 59 and 74 unchanged — + // the public form body reads neither term, so its literal forwards + // neither. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index 2a69281312c..91b6637bafd 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1139,7 +1139,10 @@ describe('[#13214] §4 preconditions', () => { // [#23050 slice 10] 10 → 11 (`ui-routes.ts`): 59 and 74, +1 each for // that slice's forwarder (the two call sites moved with the body and are // counted in both readings). - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(11); + // [#23050 slice 11] 11 → 12 (`form-routes.ts`): 59 and 74 unchanged — + // the public form body reads neither term, so its literal forwards + // neither; the ownership guard still 1 call site and 1 forwarder. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(12); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/adr-anchors/packages__rest__src__form-routes.ts.json b/scripts/adr-anchors/packages__rest__src__form-routes.ts.json new file mode 100644 index 00000000000..e594edbdb92 --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__form-routes.ts.json @@ -0,0 +1,9 @@ +{ + "file": "packages/rest/src/form-routes.ts", + "adrs": [ + "ADR-0131", + "ADR-0056", + "ADR-0112" + ], + "invariant": "The public (anonymous) form REST doors, moved here whole from `rest-server.ts` (#23050 slice 11): `GET {base}/forms/:slug` and `POST {base}/forms/:slug/submit`, mounted once per base, both reading the form through ONE resolution. ADR-0131 — the metadata an anonymous form request reads is the environment's, as every metadata read is: the view list the doors judge (a withdrawal in any body of a view name closing its form) and the object schema the resolve door narrows are both the environment's, never an organization's (its D6). ADR-0056 — the doors bypass `enforceAuth` because their authorization is DERIVED from the form's own declaration (Option A): the submit runs as `publicFormGrant`, scoped to exactly the form's target object, and writes only the fields the form's sections declare, never a server-managed anchor; a form that declares no fields publishes nothing and accepts nothing. ADR-0112 — that refusal answers the standard catalog's generic 400 code, never a minted synonym. A door that reads another layer's metadata, accepts an undeclared key or an anchor, or authorizes by anything but the form's declaration is the drift these records forbid; the bodies' own comments carry each citation." +} diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 87240d87e38..47685a11e7f 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -384,7 +384,14 @@ const MODULES = { // implementation'`) left this file with that body for `ui-routes.ts`, // declared below at the count it had here; the sum is unchanged // (15 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 = 39). - stringError: 15, + // + // 15 → 11 (#23050 slice 11): NOT progress — the same MOVE as slices 1–3, 8 + // and 10. The public form doors' four literal string `error` bodies (each + // door's 400 `INVALID_REQUEST`, `'slug is required'`, and each door's 404 + // `FORM_NOT_FOUND`) left this file with `registerFormEndpoints`' body for + // `form-routes.ts`, declared below at the count they had here; the sum is + // unchanged (11 + 13 + 8 + 1 + 0 + 0 + 0 + 0 + 1 + 0 + 1 + 4 = 39). + stringError: 11, // 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. @@ -474,7 +481,14 @@ const MODULES = { // above. The UI view body's one `{ code, error }` body (that same 501 // `NOT_IMPLEMENTED`) left with that body for `ui-routes.ts`, declared below; // the sum is unchanged (13 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 = 54). - siblingCode: 13, + // + // 13 → 7 (#23050 slice 11): NOT progress — the same MOVE as `stringError` + // above. The public form doors' six `{ code, error }` bodies (each door's + // 400 `INVALID_REQUEST` and 404 `FORM_NOT_FOUND`, the resolve door's 500 + // `FORM_RESOLVE_FAILED` and the submit door's 400 `VALIDATION_ERROR`) left + // with that body for `form-routes.ts`, declared below; the sum is unchanged + // (7 + 15 + 8 + 9 + 4 + 1 + 0 + 0 + 3 + 0 + 1 + 6 = 54). + siblingCode: 7, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -690,6 +704,23 @@ const MODULES = { stringError: 1, siblingCode: 1, }, + + // [#23050 slice 11] `RestServer#registerFormEndpoints`' body — the public + // (anonymous) form doors, `GET /forms/:slug` and `POST /forms/:slug/submit` — + // moved out of `rest-server.ts` whole, on exactly slice 1's terms above: a + // MOVE, no wire answer changed, so it inherits `dialectOnly` and the two + // dialect facts, and no write-site count. Both counts were measured on the + // moved file and equal what left `rest-server.ts` in the same PR (stringError + // 15 → 11, siblingCode 13 → 7), so each sum is unchanged. Both only ever tick + // down. Discovered by the `*-routes.ts` convention and declared in the same + // PR that created the module, so the gap never exists. + 'packages/rest/src/form-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 11 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 4, + siblingCode: 6, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */ From 162435c414365fc3a3eb8c253b7f8e6fd214c785 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:02:29 +0000 Subject: [PATCH 4/7] chore(spec): re-point the three form liveness citations to form-routes.ts (#23050 slice 11) The seat's ruling A on slice 11's open question: packages/spec/liveness/view.json reads the moved form doors in three evidence strings, so their four path references follow the code to packages/rest/src/form-routes.ts (form.sections, form.sharing x2, formViews). Consumer text, verdicts and notes unchanged. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/spec/liveness/view.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index d7ac89e5b50..795ec5923ec 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -333,7 +333,7 @@ "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 two /forms/:slug routes walk sections: resolve strips every lookup/master_detail/user field from the anonymous rendering, submit builds allowedFields from them)", + "evidence": "objectui: form renderers + spec-bridge form-view.ts:169 (audit L17); packages/rest/src/form-routes.ts registerFormEndpoints (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). Form-row `span` / `scale` read points (FormFieldBaseSchema in packages/spec/src/ui/view.zod.ts; moved out of spec source, #23434), re-read at objectui @b9814b334 on 2026-10-14: objectui @b9814b334 packages/plugin-form/src/autoLayout.ts#resolveColSpan resolves 'full' to the grid's full column count and sizes 'auto' from the widget type, objectui @b9814b334 packages/plugin-form/src/autoLayout.ts#WIDE_FIELD_TYPES makes textarea, markdown, html, richtext and repeater (via the wide `field:grid` widget) span the full count, and objectui @b9814b334 packages/components/src/renderers/form/form.tsx#spanLadderFor emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid; the widest-tier-only under-span of #17328 ended with objectui#9244 / objectui#9253). A form row's `scale` reaches `Intl.NumberFormat` through plugin-form (see field.json row `scale`)." }, "groups": { @@ -353,7 +353,7 @@ "sharing": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#findPublicFormView (`slugMatchesPublicLink(sharing.publicLink, slug)` — scanned across `form` and every `formViews` entry); packages/rest/src/rest-server.ts#resolveFormBySlug (grants `publicFormGrant` for the matched form's object)", + "evidence": "packages/rest/src/form-routes.ts#findPublicFormView (`slugMatchesPublicLink(sharing.publicLink, slug)` — scanned across `form` and every `formViews` entry); packages/rest/src/form-routes.ts#resolveFormBySlug (grants `publicFormGrant` for the matched form's object)", "note": "Framework-side consumer: public (anonymous) form endpoints opt in via FormView.sharing — /forms/:slug resolves sharing.publicLink across form + formViews (rest-server.ts:1716, :4366-4405) and grants publicFormGrant. Renderer-side the config is not read (audit L20) — the public form is served, not re-parsed client-side. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:4384` had rotted onto a comment about `api`-only route enumeration, ~4,020 lines above the form endpoints; the note's own inline pointers (`:1716`, `:4366-4405`) had gone with it and are superseded by the anchors. Re-closed by hand against 8cb96ec41." }, "submitBehavior": { @@ -389,7 +389,7 @@ "formViews": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#findPublicFormView (`const formViews = view.formViews` — every named form view is a public-link candidate); packages/spec/src/ui/view.zod.ts#expandViewContainerWithDiagnostics (flattens them into independent ViewItems)", + "evidence": "packages/rest/src/form-routes.ts#findPublicFormView (`const formViews = view.formViews` — every named form view is a public-link candidate); packages/spec/src/ui/view.zod.ts#expandViewContainerWithDiagnostics (flattens them into independent ViewItems)", "note": "Named form views. Framework: public-form slug resolution scans form + every formViews entry (rest-server.ts:4384-4405); expandViewContainer flattens them. objectui: MetadataProvider routes viewKind:'form' items into objectDef.formViews (never into the list switcher). Same inner shape as `form`; per-key classification lives under form.children. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:4384` had rotted onto the same `api`-only comment its sibling `form.sharing` cited; the note's `rest-server.ts:4384-4405` range went with it. Re-closed by hand against 8cb96ec41." } } From 12b3257238eca36d58c62e39a7d7b523cdb85ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:05:03 +0000 Subject: [PATCH 5/7] chore(changeset): @objectstack/spec patch for the re-pointed form liveness rows (#23050 slice 11) packages/spec ships liveness/ in its files[], so the three re-pointed evidence strings change what @objectstack/spec publishes: a patch changeset, as the earlier liveness-only evidence rewrites carried. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .changeset/23050-s11-form-liveness-citations.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/23050-s11-form-liveness-citations.md diff --git a/.changeset/23050-s11-form-liveness-citations.md b/.changeset/23050-s11-form-liveness-citations.md new file mode 100644 index 00000000000..129a3b6e485 --- /dev/null +++ b/.changeset/23050-s11-form-liveness-citations.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +The liveness ledger's three public-form rows cite the anonymous form doors where their code now lives, `packages/rest/src/form-routes.ts` + +Clause-②: no (only the rows' evidence paths move and every status stays `live`: `ViewSchema` accepts `form.sections`, `form.sharing` and `formViews` exactly as before, and no state count moves) + +- `liveness/view.json`: the `form.sections`, `form.sharing` and `formViews` rows cited `packages/rest/src/rest-server.ts` for the two public form doors, `GET /forms/:slug` and `POST /forms/:slug/submit`. `@objectstack/rest` moved the body of the registrar that mounts them, `RestServer#registerFormEndpoints`, to `packages/rest/src/form-routes.ts` with no behaviour change, so the rows' four path references follow it. The quoted code, the verdicts and the notes are unchanged. From be89d320c1851e2d3db8a024b649064d23ff77c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:13:08 +0000 Subject: [PATCH 6/7] chore(spec): formViews' liveness citation names the rule the form doors read it through (#23050 slice 11) The seat's amendment to ruling A for this one row: form-routes.ts never spells formViews, so the key-mention check refused the re-pointed citation. findPublicFormView reaches every form view through anonymousFormIntakeCandidates (re-exported by @objectstack/metadata-core as the same binding), declared in packages/spec/src/ui/anonymous-form-intake.ts, which reads v.formViews. The row cites that symbol; its quoted text is left as it stands. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/spec/liveness/view.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 795ec5923ec..572af595f1e 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -389,7 +389,7 @@ "formViews": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/form-routes.ts#findPublicFormView (`const formViews = view.formViews` — every named form view is a public-link candidate); packages/spec/src/ui/view.zod.ts#expandViewContainerWithDiagnostics (flattens them into independent ViewItems)", + "evidence": "packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (`const formViews = view.formViews` — every named form view is a public-link candidate); packages/spec/src/ui/view.zod.ts#expandViewContainerWithDiagnostics (flattens them into independent ViewItems)", "note": "Named form views. Framework: public-form slug resolution scans form + every formViews entry (rest-server.ts:4384-4405); expandViewContainer flattens them. objectui: MetadataProvider routes viewKind:'form' items into objectDef.formViews (never into the list switcher). Same inner shape as `form`; per-key classification lives under form.children. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:4384` had rotted onto the same `api`-only comment its sibling `form.sharing` cited; the note's `rest-server.ts:4384-4405` range went with it. Re-closed by hand against 8cb96ec41." } } From 49aecf5fea00e993e57fe1df226bba5d79945768 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:13:25 +0000 Subject: [PATCH 7/7] chore(changeset): the form liveness changeset names the formViews row's new citation (#23050 slice 11) Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .changeset/23050-s11-form-liveness-citations.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/23050-s11-form-liveness-citations.md b/.changeset/23050-s11-form-liveness-citations.md index 129a3b6e485..4e1baf8e333 100644 --- a/.changeset/23050-s11-form-liveness-citations.md +++ b/.changeset/23050-s11-form-liveness-citations.md @@ -2,8 +2,10 @@ '@objectstack/spec': patch --- -The liveness ledger's three public-form rows cite the anonymous form doors where their code now lives, `packages/rest/src/form-routes.ts` +The liveness ledger's three public-form rows cite the code that reads their keys where it now lives Clause-②: no (only the rows' evidence paths move and every status stays `live`: `ViewSchema` accepts `form.sections`, `form.sharing` and `formViews` exactly as before, and no state count moves) -- `liveness/view.json`: the `form.sections`, `form.sharing` and `formViews` rows cited `packages/rest/src/rest-server.ts` for the two public form doors, `GET /forms/:slug` and `POST /forms/:slug/submit`. `@objectstack/rest` moved the body of the registrar that mounts them, `RestServer#registerFormEndpoints`, to `packages/rest/src/form-routes.ts` with no behaviour change, so the rows' four path references follow it. The quoted code, the verdicts and the notes are unchanged. +- `liveness/view.json`: the `form.sections` and `form.sharing` rows cited `packages/rest/src/rest-server.ts` for the two public form doors, `GET /forms/:slug` and `POST /forms/:slug/submit`. `@objectstack/rest` moved the body of the registrar that mounts them, `RestServer#registerFormEndpoints`, to `packages/rest/src/form-routes.ts` with no behaviour change, so their three path references follow it. +- `liveness/view.json`: the `formViews` row cited the doors' `findPublicFormView`, which moved with that body and reaches every form view through `anonymousFormIntakeCandidates`. The row now cites that rule where it is declared, `packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates`, the code on the read path that names `formViews`. +- The quoted code, the verdicts and the notes are unchanged.