Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/public-form-withdrawal-one-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/metadata-core': minor
'@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: `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.
7 changes: 4 additions & 3 deletions content/docs/references/ui/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Both modes:
- Honor `?prefill_<field>=<value>` 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

Expand Down Expand Up @@ -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
},
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions packages/metadata-core/src/anonymous-form-intake.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>]>([
['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<string, unknown>) => ({
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');
});
});
83 changes: 83 additions & 0 deletions packages/metadata-core/src/anonymous-form-intake.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
/** 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<string, unknown>;
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<string, any>;
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<string, unknown>).sharing);
if (slug === null) continue;
open.push({ form: form as Record<string, any>, ...(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();
}
6 changes: 6 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
37 changes: 0 additions & 37 deletions packages/metadata-protocol/src/anonymous-form-intake.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> => {
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);
});
});
Loading
Loading