From 9cd8be9b6421644fa5810f826474287337f4efd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:34:33 +0000 Subject: [PATCH 1/7] fix(rest): one anonymous-intake rule for public forms, shared with the metadata protocol (WIP) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/anonymous-form-intake.ts | 83 +++++++++++++++++++ packages/metadata-core/src/index.ts | 6 ++ .../src/anonymous-form-intake.ts | 37 --------- packages/metadata-protocol/src/protocol.ts | 4 +- packages/rest/src/rest-server.ts | 51 ++++-------- packages/spec/src/ui/sharing.zod.ts | 11 +-- 6 files changed, 114 insertions(+), 78 deletions(-) create mode 100644 packages/metadata-core/src/anonymous-form-intake.ts delete mode 100644 packages/metadata-protocol/src/anonymous-form-intake.ts diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts new file mode 100644 index 00000000000..bf5a099a48b --- /dev/null +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which forms a `view` body opens to anonymous intake — the ONE rule. + * + * The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`, + * `registerFormEndpoints` in `@objectstack/rest`) serve exactly the candidates + * this module returns, and `@objectstack/metadata-protocol` judges an + * organization-scoped `view` write by the slug set it projects. Both import it + * from here so the doors and the write-time judgement can never disagree about + * which forms are published. + * + * A form candidate is open to anonymous intake when its `sharing` (the spec's + * `SharingConfigSchema`) declares all three of: + * + * - `enabled === true` — "Enable public sharing". The schema defaults it to + * `false`, and a parsed body carries that default, so an absent `enabled` + * reads as not shared here too: a raw body and its parsed form get the same + * answer. + * - `allowAnonymous === true` — "Allow access without authentication". + * - a non-empty `publicLink` naming the slug. + * + * Clearing either switch withdraws the form from every anonymous door. + * + * The candidates are the three shapes a view carries a form in: the nested + * `form`, every `formViews` entry, and the flattened `config` of a + * `viewKind: 'form'` item. + */ + +/** A form candidate of a view that is open to anonymous intake. */ +export interface AnonymousFormIntakeCandidate { + /** The form view object (the nested `form`, a `formViews` entry, or the flattened `config`). */ + form: Record; + /** The `formViews` key, or the view name for a flattened `viewKind: 'form'` item. */ + key?: string; + /** The slug its `publicLink` names, normalised (`/forms/x`, `forms/x` and `x` are one slug). */ + slug: string; +} + +/** Normalise a `publicLink` to the slug the doors compare: `/forms/x`, `forms/x` and `x` are one slug. */ +export function publicFormSlug(publicLink: string): string { + return publicLink.replace(/^\/+/, '').replace(/^forms\//, ''); +} + +/** The slug a form's `sharing` opens to anonymous intake, or `null` when it opens none. */ +export function anonymousFormIntakeSlug(sharing: unknown): string | null { + if (!sharing || typeof sharing !== 'object') return null; + const s = sharing as Record; + if (s.enabled !== true) return null; + if (s.allowAnonymous !== true) return null; + if (typeof s.publicLink !== 'string' || !s.publicLink) return null; + return publicFormSlug(s.publicLink); +} + +/** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */ +export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] { + if (!view || typeof view !== 'object') return []; + const v = view as Record; + const forms: Array<{ form: unknown; key?: string }> = []; + if (v.form && typeof v.form === 'object') forms.push({ form: v.form }); + if (v.formViews && typeof v.formViews === 'object') { + for (const [key, fv] of Object.entries(v.formViews)) forms.push({ form: fv, key }); + } + if (v.viewKind === 'form' && v.config && typeof v.config === 'object') { + forms.push({ form: v.config, key: v.name }); + } + const open: AnonymousFormIntakeCandidate[] = []; + for (const { form, key } of forms) { + if (!form || typeof form !== 'object') continue; + const slug = anonymousFormIntakeSlug((form as Record).sharing); + if (slug === null) continue; + open.push({ form: form as Record, ...(key !== undefined ? { key } : {}), slug }); + } + return open; +} + +/** + * The sorted, de-duplicated slug set a `view` body opens to anonymous intake. + * Two bodies with the same set open exactly the same anonymous doors. + */ +export function anonymousFormIntakeSlugs(view: unknown): string[] { + return [...new Set(anonymousFormIntakeCandidates(view).map((c) => c.slug))].sort(); +} diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 441cddc7b89..b3ad070b1f1 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -137,3 +137,9 @@ export * from './record-organization.js'; // metadata-protocol, and a boot log with its own opinion about which // declarations the registry will take is the very defect this card closes. export * from './object-field-type.js'; + +// Which forms a `view` body opens to anonymous intake. The enforcing doors live +// in `@objectstack/rest` and the write-time judgement of an organization-scoped +// `view` write in `@objectstack/metadata-protocol`; both read this one rule, so +// a form withdrawn by either declared switch is withdrawn everywhere. +export * from './anonymous-form-intake.js'; diff --git a/packages/metadata-protocol/src/anonymous-form-intake.ts b/packages/metadata-protocol/src/anonymous-form-intake.ts deleted file mode 100644 index 5a912cd4cff..00000000000 --- a/packages/metadata-protocol/src/anonymous-form-intake.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Which public-form slugs a `view` body opens to anonymous intake. - * - * The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`, - * `registerFormEndpoints` in `@objectstack/rest`) serve a form when one of a - * view's form candidates carries `sharing.allowAnonymous === true` and a - * `sharing.publicLink` that names the slug. The candidates are the same three - * shapes those doors scan: the nested `form`, every `formViews` entry, and the - * flattened `config` of a `viewKind: 'form'` item. - * - * Returns the sorted, de-duplicated slug set, normalised the way the doors - * compare it (`/forms/x`, `forms/x` and `x` are one slug). Two bodies with the - * same set open exactly the same anonymous doors. - */ -export function anonymousFormIntakeSlugs(view: unknown): string[] { - if (!view || typeof view !== 'object') return []; - const v = view as Record; - const sharings: unknown[] = []; - if (v.form && typeof v.form === 'object') sharings.push(v.form.sharing); - if (v.formViews && typeof v.formViews === 'object') { - for (const fv of Object.values(v.formViews)) { - if (fv && typeof fv === 'object') sharings.push((fv as any).sharing); - } - } - if (v.viewKind === 'form' && v.config && typeof v.config === 'object') sharings.push(v.config.sharing); - const slugs = new Set(); - for (const s of sharings) { - if (!s || typeof s !== 'object') continue; - const sharing = s as Record; - if (sharing.allowAnonymous !== true) continue; - if (typeof sharing.publicLink !== 'string' || !sharing.publicLink) continue; - slugs.add(sharing.publicLink.replace(/^\/+/, '').replace(/^forms\//, '')); - } - return [...slugs].sort(); -} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index efa95ff7517..52264f8c00c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -38,7 +38,6 @@ import { // [#7560] ADR-0070's read-only-package rule, shared with the `/packages` // lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`. import { isWritablePackage as isWritablePackageShared } from './package-writability.js'; -import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js'; import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. @@ -90,6 +89,9 @@ import { // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer // fallback so a hydrated row is never answered as the code layer. isTenantAuthored, + // The one rule for which forms a `view` body opens to anonymous intake — + // the same rule the anonymous form doors in `@objectstack/rest` serve by. + anonymousFormIntakeSlugs, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f72f18e2859..b1dacf000d4 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -71,6 +71,9 @@ import { // transports — never a REST-local restatement. metaWriteCapabilityVerdict, type MetaWriteCapabilityVerdict, + // Which form candidates the anonymous form doors serve — the one rule the + // metadata protocol also judges organization-scoped `view` writes by. + anonymousFormIntakeCandidates, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -10517,8 +10520,10 @@ export class RestServer { * Register public (anonymous) form endpoints. * * Public forms are opt-in: a `FormView` becomes accessible to anonymous - * visitors only when `sharing.allowAnonymous === true` AND a - * `sharing.publicLink` slug is configured. Two routes are registered: + * visitors only when `sharing.enabled === true`, `sharing.allowAnonymous + * === true` AND a `sharing.publicLink` slug is configured + * (`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`). Two + * routes are registered: * * GET {basePath}/forms/:slug → resolved form spec * POST {basePath}/forms/:slug/submit → INSERT record (no auth required) @@ -10536,48 +10541,24 @@ export class RestServer { * * The matched FormView's parent ViewSchema is found by scanning * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect - * `form.sharing` and every entry in `formViews`; the first FormView - * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`) - * wins. The response carries the matched form view under `form` and + * `form.sharing`, every entry in `formViews` and a flattened form item's + * `config.sharing`; the first open FormView whose `sharing.publicLink` + * matches `/forms/:slug` (or just `:slug`) wins. The response carries the matched form view under `form` and * the inferred target object, matching what the frontend's * `mapViewSpecToEmbeddableConfig` expects. */ private registerFormEndpoints(basePath: string): void { const isScoped = basePath.includes('/environments/:environmentId'); - const slugMatchesPublicLink = (publicLink: string | undefined, slug: string): boolean => { - if (!publicLink || typeof publicLink !== 'string') return false; - // Accept `/forms/:slug`, `forms/:slug`, or a bare slug. - const normalized = publicLink.replace(/^\/+/, '').replace(/^forms\//, ''); - return normalized === slug; - }; - + // 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. const findPublicFormView = (views: any[], slug: string): { view: any; form: any; object: string } | null => { for (const view of views ?? []) { if (!view || typeof view !== 'object') continue; - const candidates: Array<{ form: any; key?: string }> = []; - // Authoring/nested shape (defineView): { form, formViews: { key: {...} } }. - if (view.form && view.form.sharing) candidates.push({ form: view.form }); - const formViews = view.formViews; - if (formViews && typeof formViews === 'object') { - for (const [key, fv] of Object.entries(formViews)) { - if (fv && typeof fv === 'object' && (fv as any).sharing) { - candidates.push({ form: fv as any, key }); - } - } - } - // Flattened registered shape (getMetaItems → one item per view: - // { name, object, viewKind:'form', config:{ data, sections, sharing } }). - // A form view carries its sharing under `config`; without this branch - // public-form resolution silently fails for the standard view metadata. - if (view.viewKind === 'form' && view.config && typeof view.config === 'object' - && (view.config as any).sharing) { - candidates.push({ form: view.config, key: view.name }); - } - for (const c of candidates) { - const sharing = c.form?.sharing; - if (!sharing || sharing.allowAnonymous !== true) continue; - if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue; + for (const c of anonymousFormIntakeCandidates(view)) { + if (c.slug !== slug) continue; const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index e0997be504f..bb6f7218757 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -14,9 +14,10 @@ * * - `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` * carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime - * really reads it: `rest-server.ts` mounts the anonymous form endpoints only - * when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug - * matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, + * really reads it: `rest-server.ts` serves the anonymous form endpoints only + * when `sharing.enabled === true`, `sharing.allowAnonymous === true` and a + * `sharing.publicLink` slug matches (`anonymousFormIntakeCandidates` in + * `@objectstack/metadata-core`). Both example apps author it (`app-showcase` `inquiry.view.ts`, * `app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. * - `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) — * see the block below where it stood. @@ -75,8 +76,8 @@ export const SharingConfigSchema = lazySchema(() => strictObject({ shareUrl: 'publicLink', shareLink: 'publicLink', slug: 'publicLink', - // Anonymous access — the key `rest-server.ts` actually gates the public - // form routes on. + // Anonymous access — one of the two switches (with `enabled`) the public + // form routes in `rest-server.ts` gate on. anonymous: 'allowAnonymous', allowGuest: 'allowAnonymous', allowGuests: 'allowAnonymous', From 700db67e7255437179620ceab1b1b14ed1d46474 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:35:57 +0000 Subject: [PATCH 2/7] test: pin either declared public-form switch on both anonymous doors (WIP) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/anonymous-form-intake.test.ts | 81 +++++++++++++++++++ .../protocol.org-scoped-write-refused.test.ts | 12 +++ ...ase-public-form-withdrawal.dogfood.test.ts | 40 +++++++-- .../src/public-form-routes.stored-row.test.ts | 2 +- packages/rest/src/public-form-routes.test.ts | 2 +- .../rest/src/public-form-withdrawal.test.ts | 38 ++++++++- 6 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 packages/metadata-core/src/anonymous-form-intake.test.ts diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts new file mode 100644 index 00000000000..24c9a89cb89 --- /dev/null +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SharingConfigSchema } from '@objectstack/spec/ui'; +import { + anonymousFormIntakeCandidates, + anonymousFormIntakeSlug, + anonymousFormIntakeSlugs, + publicFormSlug, +} from './anonymous-form-intake.js'; + +const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }; + +describe('anonymousFormIntakeSlug — which sharing opens a form to anonymous intake', () => { + it('both switches on and a publicLink: open, slug normalised', () => { + expect(anonymousFormIntakeSlug(OPEN)).toBe('contact-us'); + expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'forms/contact-us' })).toBe('contact-us'); + expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'contact-us' })).toBe('contact-us'); + }); + + it.each<[string, Record]>([ + ['enabled: false', { ...OPEN, enabled: false }], + ['enabled absent', { allowAnonymous: true, publicLink: '/forms/contact-us' }], + ['allowAnonymous: false', { ...OPEN, allowAnonymous: false }], + ['allowAnonymous absent', { enabled: true, publicLink: '/forms/contact-us' }], + ['publicLink absent', { enabled: true, allowAnonymous: true }], + ['publicLink empty', { ...OPEN, publicLink: '' }], + ['a truthy non-boolean switch', { ...OPEN, enabled: 'true' }], + ])('%s: closed', (_label, sharing) => { + expect(anonymousFormIntakeSlug(sharing)).toBeNull(); + }); + + it('a raw body and its parse get the same answer (the schema defaults `enabled` to false)', () => { + for (const raw of [OPEN, { allowAnonymous: true, publicLink: '/forms/contact-us' }, { ...OPEN, enabled: false }]) { + expect(anonymousFormIntakeSlug(SharingConfigSchema.parse(raw))).toBe(anonymousFormIntakeSlug(raw)); + } + }); + + it('not an object: closed', () => { + expect(anonymousFormIntakeSlug(undefined)).toBeNull(); + expect(anonymousFormIntakeSlug(null)).toBeNull(); + expect(anonymousFormIntakeSlug('x')).toBeNull(); + }); +}); + +describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs — the three form shapes of a view', () => { + const view = (sharing: Record) => ({ + name: 'inquiry.contact', + object: 'inquiry', + form: { data: { object: 'inquiry' }, sharing: { ...sharing, publicLink: '/forms/nested' } }, + formViews: { + a: { sharing: { ...sharing, publicLink: '/forms/a' } }, + b: { sharing: { ...OPEN, enabled: false, publicLink: '/forms/b' } }, + }, + viewKind: 'form', + config: { sharing: { ...sharing, publicLink: 'forms/flat' } }, + }); + + it('scans the nested form, every formViews entry and the flattened config, open ones only', () => { + const c = anonymousFormIntakeCandidates(view(OPEN)); + expect(c.map((x) => [x.key, x.slug])).toEqual([ + [undefined, 'nested'], + ['a', 'a'], + ['inquiry.contact', 'flat'], + ]); + expect(anonymousFormIntakeSlugs(view(OPEN))).toEqual(['a', 'flat', 'nested']); + }); + + it('withdrawn through either switch: no candidate on any shape', () => { + expect(anonymousFormIntakeSlugs(view({ ...OPEN, enabled: false }))).toEqual([]); + expect(anonymousFormIntakeSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual([]); + }); + + it('de-duplicates and sorts slugs; tolerates non-object input', () => { + expect(anonymousFormIntakeSlugs({ formViews: { x: { sharing: OPEN }, y: { sharing: { ...OPEN, publicLink: 'contact-us' } } } })) + .toEqual(['contact-us']); + expect(anonymousFormIntakeSlugs(null)).toEqual([]); + expect(anonymousFormIntakeSlugs({ formViews: { x: null } })).toEqual([]); + expect(publicFormSlug('//forms/x')).toBe('x'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index c0029686071..0c22dd43ca7 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -604,6 +604,18 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); }); + it('walled: an org-scoped withdrawal through `sharing.enabled` alone is refused the same way', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + const body = FORM_VIEW(true); + body.config.sharing.enabled = false; + + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: body, organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + it('walled: an org-scoped draft of the withdrawal is refused too', async () => { const { protocol, rows } = makeTenancyProtocol(null); await publishEnvWide(protocol); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts index c844f68bcd0..99761833b2b 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -15,8 +15,10 @@ // `404 FORM_NOT_FOUND`; // - no `showcase_inquiry` row is written by the refused submit. // -// Both sides are pinned: republishing at the same scope restores both doors, -// and after the organization republish the row lands in that organization. +// The form is withdrawn by either declared switch: `allowAnonymous: false`, or +// `enabled: false` (absent reads as the schema default, false). Both sides are +// pinned: republishing at the same scope restores both doors, and after the +// organization republish the row lands in that organization. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -57,10 +59,17 @@ describe('showcase: withdrawing the public contact form closes every intake door return { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed }; }; - /** Save the form with `allowAnonymous` set, at the admin's current session scope. */ - const save = async (allowAnonymous: boolean): Promise => { + /** + * Save the form at the admin's current session scope, with `allowAnonymous` + * set (a boolean) or with these `sharing` keys replaced (`undefined` deletes one). + */ + const save = async (change: boolean | Record): Promise => { const body = structuredClone(published); - body.config.sharing.allowAnonymous = allowAnonymous; + const patch = typeof change === 'boolean' ? { allowAnonymous: change } : change; + for (const [k, v] of Object.entries(patch)) { + if (v === undefined) delete body.config.sharing[k]; + else body.config.sharing[k] = v; + } const res = await stack.apiAs(admin, 'PUT', VIEW, body); const json = (await res.json()) as { message?: string }; expect(res.status, JSON.stringify(json)).toBe(200); @@ -109,6 +118,27 @@ describe('showcase: withdrawing the public contact form closes every intake door expect(open.landed).toHaveLength(1); }); + it('withdrawn env-wide through `sharing.enabled: false` alone: both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => { + expect(published.config.sharing.enabled).toBe(true); + expect(await save({ enabled: false, allowAnonymous: true })).toMatch(/env-wide/); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); + + // `enabled` absent reads as the schema default (false): still closed. + expect(await save({ enabled: undefined, allowAnonymous: true })).toMatch(/env-wide/); + const absent = await probe(); + expect([absent.get, absent.getCode, absent.submit, absent.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(absent.landed).toHaveLength(0); + + expect(await save({ enabled: true, allowAnonymous: true })).toMatch(/env-wide/); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); + }); + it('withdrawn in the admin\'s organization: both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => { const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1); 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 d721ef5aac0..8e3694c795e 100644 --- a/packages/rest/src/public-form-routes.stored-row.test.ts +++ b/packages/rest/src/public-form-routes.stored-row.test.ts @@ -112,7 +112,7 @@ async function persistedBody(name: string, item: unknown): Promise { // ─── the fixtures an author writes in Studio ──────────────────────────────── -const SHARING = { allowAnonymous: true, publicLink: '/forms/contact' }; +const SHARING = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact' }; const DATA = { provider: 'object', object: 'lead' }; /** The section every case declares. */ diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts index e18bc9149b7..ce781877cac 100644 --- a/packages/rest/src/public-form-routes.test.ts +++ b/packages/rest/src/public-form-routes.test.ts @@ -49,7 +49,7 @@ function formView(sections: any[] | undefined) { config: { data: { object: 'ticket' }, sections, - sharing: { allowAnonymous: true, publicLink: '/forms/test' }, + sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/test' }, }, }; } diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index dbef4589bb6..13817605e69 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -42,7 +42,7 @@ function mockRes() { return res; } -function formView(allowAnonymous: boolean) { +function formView(allowAnonymous: boolean, sharing?: Record) { return { name: 'contact', object: 'inquiry', @@ -50,7 +50,7 @@ function formView(allowAnonymous: boolean) { config: { data: { object: 'inquiry' }, sections: [{ fields: ['name', 'email'] }], - sharing: { allowAnonymous, publicLink: '/forms/contact-us' }, + sharing: sharing ?? { enabled: true, allowAnonymous, publicLink: '/forms/contact-us' }, }, }; } @@ -80,6 +80,8 @@ interface Setup { inOrg?: boolean; /** The tenancy provider's behaviour. */ tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; + /** Replaces the form's whole `sharing` on every read (the `envWide`/`inOrg` switch is then ignored). */ + sharing?: Record; } function build(setup: Setup) { @@ -87,7 +89,7 @@ function build(setup: Setup) { const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => { if (req.type === 'view') { const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; - return [formView(effective)]; + return [formView(effective, setup.sharing)]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -195,3 +197,33 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { expect(s.getMetaItems).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'view' })); }); }); + +describe('either declared switch withdraws a public form from every anonymous door', () => { + const LINK = '/forms/contact-us'; + const withdrawn: Array<[string, Record]> = [ + ['enabled: false', { enabled: false, allowAnonymous: true, publicLink: LINK }], + ['enabled absent (the schema default is false)', { allowAnonymous: true, publicLink: LINK }], + ['allowAnonymous: false', { enabled: true, allowAnonymous: false, publicLink: LINK }], + ['both cleared', { enabled: false, allowAnonymous: false, publicLink: LINK }], + ]; + for (const tenancy of ['org', 'not-registered'] as const) { + for (const [label, sharing] of withdrawn) { + it(`${label} (tenancy ${tenancy}): both doors answer 404 FORM_NOT_FOUND and nothing is written`, async () => { + const s = build({ envWide: true, tenancy, sharing }); + const get = await s.get(); + expect(get.statusCode).toBe(404); + expect(get.body.code).toBe('FORM_NOT_FOUND'); + const post = await s.post(); + expect(post.statusCode).toBe(404); + expect(post.body.code).toBe('FORM_NOT_FOUND'); + expect(s.createData).not.toHaveBeenCalled(); + }); + } + it(`published, enabled and allowAnonymous both true (tenancy ${tenancy}, control): both doors accept`, async () => { + const s = build({ envWide: false, tenancy, sharing: { enabled: true, allowAnonymous: true, publicLink: LINK } }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); + }); + } +}); From 618a3d9a1a1d51ed8768880c347aef511b1d6f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:36:24 +0000 Subject: [PATCH 3/7] test(dogfood): walled posture pin for the enabled switch (WIP) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- ...lic-form-withdrawal-walled.dogfood.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts index f68e0e4d264..76b91c3d498 100644 --- a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -203,4 +203,30 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = expect([open.get, open.submit]).toEqual([200, 201]); expect(open.landed).toHaveLength(1); }); + + it('withdrawn through `sharing.enabled: false` alone: refused org-scoped; env-wide both doors 404 and nothing lands', async () => { + const withEnabled = (enabled: boolean): Record => { + const body = withAnonymous(true); + body.config.sharing.enabled = enabled; + return body; + }; + await setActive(orgId); + const refused = await put(withEnabled(false)); + expect(refused.status, JSON.stringify(refused.json)).toBe(403); + expect(JSON.stringify(refused.json)).toMatch(/NOT_OVERRIDABLE/); + + await setActive(null); + const off = await put(withEnabled(false)); + expect(off.status, JSON.stringify(off.json)).toBe(200); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); + + const on = await put(withEnabled(true)); + expect(on.status, JSON.stringify(on.json)).toBe(200); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); + }); }); From 5ccab09f63a4f19bacbd0878827df343abac4953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:48:07 +0000 Subject: [PATCH 4/7] docs(forms): a public form needs both sharing switches (WIP) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- content/docs/ui/forms.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index a6fec3296a8..893eb2d7704 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -20,7 +20,7 @@ Both modes: - Honor `?prefill_=` URL params - Honor `submitBehavior` (thank-you / redirect / continue / next-record) — with **mode-aware defaults** when it is omitted (see [§8](#8-submitbehavior--what-happens-after-submit)) -A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. +A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.enabled: true` and `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint. ## Architecture at a glance @@ -86,7 +86,7 @@ export default defineView({ }, ], sharing: { - enabled: true, + enabled: true, // ← required (the schema default is false) allowAnonymous: true, // ← required publicLink: '/forms/contact-us', // ← the slug ":contact-us" wires this view to the public route }, @@ -99,7 +99,7 @@ export default defineView({ > - The slug in `publicLink` (`contact-us`) becomes the `:slug` segment in the REST URL. > - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list. > - A form whose sections declare **no** fields collects nothing, so the submit is **refused** (`400 VALIDATION_ERROR`) rather than accepting whatever the caller sent (#6920). Its `GET /forms/:slug` publishes no schema either (#6601) — declare the fields and both planes come alive together. -> - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed. +> - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed. ## 2. (Optional) Create the `guest_portal` permission set @@ -230,7 +230,7 @@ Errors: | `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) | | `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) | | `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object | -| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view | +| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` | | `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code | The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws. From 0164be245829d298bef7ea8ae5c8a1691e2f9d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:51:25 +0000 Subject: [PATCH 5/7] chore: changeset and regenerated sharing reference for the one anonymous-intake rule Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/public-form-withdrawal-one-rule.md | 7 +++++++ content/docs/references/ui/sharing.mdx | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .changeset/public-form-withdrawal-one-rule.md diff --git a/.changeset/public-form-withdrawal-one-rule.md b/.changeset/public-form-withdrawal-one-rule.md new file mode 100644 index 00000000000..aa0538fea4f --- /dev/null +++ b/.changeset/public-form-withdrawal-one-rule.md @@ -0,0 +1,7 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +'@objectstack/rest': patch +--- + +Public forms: every declared means of withdrawing a form from anonymous intake is now honoured by every anonymous form door. Which forms a `view` opens to anonymous intake is now decided by one rule, `anonymousFormIntakeCandidates` (new in `@objectstack/metadata-core`, alongside `anonymousFormIntakeSlugs`, `anonymousFormIntakeSlug` and `publicFormSlug`), read by both the anonymous form endpoints in `@objectstack/rest` and the organization-scoped `view` write check in `@objectstack/metadata-protocol`, so the two can no longer disagree. A form is served anonymously only when its `sharing` config declares public sharing as `SharingConfigSchema` defines it; see the public forms guide for the required keys. diff --git a/content/docs/references/ui/sharing.mdx b/content/docs/references/ui/sharing.mdx index ec15af0c872..d575ea13a37 100644 --- a/content/docs/references/ui/sharing.mdx +++ b/content/docs/references/ui/sharing.mdx @@ -17,9 +17,10 @@ asymmetry survives as the reason this file reads the way it does: - `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing` carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime - really reads it: `rest-server.ts` mounts the anonymous form endpoints only - when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug - matches. Both example apps author it (`app-showcase` `inquiry.view.ts`, + really reads it: `rest-server.ts` serves the anonymous form endpoints only + when `sharing.enabled === true`, `sharing.allowAnonymous === true` and a + `sharing.publicLink` slug matches (`anonymousFormIntakeCandidates` in + `@objectstack/metadata-core`). Both example apps author it (`app-showcase` `inquiry.view.ts`, `app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14. - `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) — see the block below where it stood. From f38427a9ce2920a435c3c3d5ed68234e85ee56de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:19:10 +0000 Subject: [PATCH 6/7] chore: name the public-form migration in the changeset Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/public-form-withdrawal-one-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/public-form-withdrawal-one-rule.md b/.changeset/public-form-withdrawal-one-rule.md index aa0538fea4f..d41387a8bb6 100644 --- a/.changeset/public-form-withdrawal-one-rule.md +++ b/.changeset/public-form-withdrawal-one-rule.md @@ -4,4 +4,4 @@ '@objectstack/rest': patch --- -Public forms: every declared means of withdrawing a form from anonymous intake is now honoured by every anonymous form door. Which forms a `view` opens to anonymous intake is now decided by one rule, `anonymousFormIntakeCandidates` (new in `@objectstack/metadata-core`, alongside `anonymousFormIntakeSlugs`, `anonymousFormIntakeSlug` and `publicFormSlug`), read by both the anonymous form endpoints in `@objectstack/rest` and the organization-scoped `view` write check in `@objectstack/metadata-protocol`, so the two can no longer disagree. A form is served anonymously only when its `sharing` config declares public sharing as `SharingConfigSchema` defines it; see the public forms guide for the required keys. +Public forms: every declared means of withdrawing a form from anonymous intake is now honoured by every anonymous form door. Which forms a `view` opens to anonymous intake is now decided by one rule, `anonymousFormIntakeCandidates` (new in `@objectstack/metadata-core`, alongside `anonymousFormIntakeSlugs`, `anonymousFormIntakeSlug` and `publicFormSlug`), read by both the anonymous form endpoints in `@objectstack/rest` and the organization-scoped `view` write check in `@objectstack/metadata-protocol`, so the two can no longer disagree. A form is served anonymously only when its `sharing` config declares public sharing as `SharingConfigSchema` defines it: `sharing.enabled: true`, `sharing.allowAnonymous: true` and a `sharing.publicLink` slug. `enabled` defaults to `false`, so a form that set only `allowAnonymous` and `publicLink` is no longer served on the anonymous endpoints (`404 FORM_NOT_FOUND`). Migration: add `enabled: true` to the form's `sharing` block (and to any stored overlay of it) to keep it public; see the public forms guide. From 769594d9c68e109be0535ac9ee3b2e5fbd168ef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:37:38 +0000 Subject: [PATCH 7/7] chore: grade metadata-core minor for its new anonymous-intake exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared rule widens @objectstack/metadata-core's public index by five exported symbols, which takes at least minor (Clause-② yes, widening). Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/public-form-withdrawal-one-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/public-form-withdrawal-one-rule.md b/.changeset/public-form-withdrawal-one-rule.md index d41387a8bb6..5c7cadc15f0 100644 --- a/.changeset/public-form-withdrawal-one-rule.md +++ b/.changeset/public-form-withdrawal-one-rule.md @@ -1,5 +1,5 @@ --- -'@objectstack/metadata-core': patch +'@objectstack/metadata-core': minor '@objectstack/metadata-protocol': patch '@objectstack/rest': patch ---