Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22047-spec-ui-anonymous-form-intake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-core': patch
---

`@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve

Clause-②: yes (widening)

- **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read.
- **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item.
- **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them.
- **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture.
6 changes: 4 additions & 2 deletions content/docs/references/ui/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ asymmetry survives as the reason this file reads the way it does:
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.
`anonymous-form-intake.ts` beside this module, which
`@objectstack/metadata-core` re-exports to the server's doors). 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
21 changes: 21 additions & 0 deletions packages/metadata-core/src/anonymous-form-intake.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import * as specUi from '@objectstack/spec/ui';
import { SharingConfigSchema } from '@objectstack/spec/ui';
import {
anonymousFormIntakeCandidates,
Expand All @@ -15,6 +16,8 @@ import {
anonymousFormSharingPath,
publicFormSlug,
} from './anonymous-form-intake.js';
import * as intakeModule from './anonymous-form-intake.js';
import * as metadataCore from './index.js';

const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };

Expand Down Expand Up @@ -315,3 +318,21 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same
});
});
});

// The candidates half is declared in `@objectstack/spec/ui` and re-exported by
// this package. "One copy" is checkable only as IDENTITY: a wrapper or a copy
// answers the same today and drifts tomorrow, while the same binding cannot.
describe('the candidates half is the spec binding itself, re-exported (one copy, not a copy)', () => {
const NAMES = [
'publicFormSlug',
'anonymousFormIntakeSlug',
'anonymousFormIntakeCandidates',
'anonymousFormIntakeSlugs',
] as const;

it.each(NAMES)('%s: this module and the package barrel export the @objectstack/spec/ui function', (name) => {
expect(typeof specUi[name]).toBe('function');
expect(intakeModule[name]).toBe(specUi[name]);
expect(metadataCore[name]).toBe(specUi[name]);
});
});
91 changes: 22 additions & 69 deletions packages/metadata-core/src/anonymous-form-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,20 @@
* 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 half — which `sharing` opens a form (`enabled === true`,
* `allowAnonymous === true` and a non-empty `publicLink` naming the slug) and
* the three shapes a view carries a form in — is declared in
* `@objectstack/spec/ui` (`anonymous-form-intake.ts`, beside the
* `SharingConfigSchema` it reads), whose docblock is the authority on it. This
* module re-exports those bindings unchanged, so a console that imports them
* from the spec reads the same rule the doors serve. Clearing either switch
* withdraws the form from every anonymous door.
*
* A withdrawal is a kill switch: any metadata layer whose body of the same
* view name explicitly withdraws the form (the link kept, a switch set to
* `false`), matched by slot or by slug, closes it, and layering may only narrow
* intake, never re-open it ({@link anonymousFormIntakeWithdrawnIn}).
*
* 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.
*
* [#21476] The module also answers the second question an open form raises:
* can it take an anonymous submission on THIS deployment's posture
* ({@link anonymousFormIntakeUnavailability})? Three readers ask it — both
Expand All @@ -46,63 +39,23 @@ import {
postureEnforcesWall,
type TenancyPosture,
} from '@objectstack/spec/security';
import { publicFormSlug, type AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';
import { applyInjectedSystemColumns } from './injected-system-columns.js';
import { resolveRecordWallOrganizationField } from './record-organization.js';

/** 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();
}
// The candidates half of the rule — which form candidates a `view` body opens
// to anonymous intake — lives in `@objectstack/spec/ui` beside the
// `SharingConfigSchema` it reads, so a console can import the same rule the
// doors serve. Re-exported here as the SAME bindings (never a wrapper or a
// copy), so every server caller keeps importing it from this package and the
// two entries cannot disagree; the identity is pinned in this module's test.
export {
anonymousFormIntakeCandidates,
anonymousFormIntakeSlug,
anonymousFormIntakeSlugs,
publicFormSlug,
} from '@objectstack/spec/ui';
export type { AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';

/** The object an open form candidate submits into: the form's own `data.object`, else the view's. */
export function anonymousFormObjectName(view: unknown, form: unknown): string | undefined {
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"AddRecordConfig (type)",
"AddRecordConfigParsed (type)",
"AddRecordConfigSchema (const)",
"AnonymousFormIntakeCandidate (interface)",
"App (const)",
"App (type)",
"AppBranding (type)",
Expand Down Expand Up @@ -475,6 +476,9 @@
"WidgetColorVariant (type)",
"WidgetColorVariantSchema (const)",
"actionForm (const)",
"anonymousFormIntakeCandidates (function)",
"anonymousFormIntakeSlug (function)",
"anonymousFormIntakeSlugs (function)",
"appForm (const)",
"chartAggregateCategoryKey (function)",
"chartAggregateResultKeys (function)",
Expand Down Expand Up @@ -518,6 +522,7 @@
"pageComponentSlotPositions (function)",
"pageForm (const)",
"partitionAssembledViewArtifacts (function)",
"publicFormSlug (function)",
"reactBlockTagFor (function)",
"reportForm (const)",
"reportSelectionOrder (function)",
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"AddRecordConfig": "src/ui/view.zod.ts#AddRecordConfig (type)",
"AddRecordConfigParsed": "src/ui/view.zod.ts#AddRecordConfigParsed (type)",
"AddRecordConfigSchema": "src/ui/view.zod.ts#AddRecordConfigSchema (const)",
"AnonymousFormIntakeCandidate": "src/ui/anonymous-form-intake.ts#AnonymousFormIntakeCandidate (interface)",
"App": "src/ui/app.zod.ts#App (type)",
"AppBranding": "src/ui/app.zod.ts#AppBranding (type)",
"AppBrandingSchema": "src/ui/app.zod.ts#AppBrandingSchema (const)",
Expand Down Expand Up @@ -460,6 +461,9 @@
"WidgetColorVariant": "src/ui/dashboard.zod.ts#WidgetColorVariant (type)",
"WidgetColorVariantSchema": "src/ui/dashboard.zod.ts#WidgetColorVariantSchema (const)",
"actionForm": "src/ui/action.form.ts#actionForm (const)",
"anonymousFormIntakeCandidates": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (function)",
"anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)",
"anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)",
"appForm": "src/ui/app.form.ts#appForm (const)",
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
Expand Down Expand Up @@ -503,6 +507,7 @@
"pageComponentSlotPositions": "src/ui/component.zod.ts#pageComponentSlotPositions (function)",
"pageForm": "src/ui/page.form.ts#pageForm (const)",
"partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)",
"publicFormSlug": "src/ui/anonymous-form-intake.ts#publicFormSlug (function)",
"reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)",
"reportForm": "src/ui/report.form.ts#reportForm (const)",
"reportSelectionOrder": "src/ui/report.zod.ts#reportSelectionOrder (function)",
Expand Down
146 changes: 146 additions & 0 deletions packages/spec/src/ui/anonymous-form-intake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { SharingConfigSchema } from './sharing.zod';
import {
anonymousFormIntakeCandidates,
anonymousFormIntakeSlug,
anonymousFormIntakeSlugs,
publicFormSlug,
} from './anonymous-form-intake';
import * as uiEntry from './index';

// The candidates half of the anonymous-intake rule, as the spec declares it.
// `@objectstack/metadata-core` re-exports these exact bindings to the server's
// doors (pinned there by identity), so what this file pins is what the doors
// serve and what a console importing `@objectstack/spec/ui` reads.

const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };

describe('the /ui entry exports the candidates half', () => {
it.each([
'publicFormSlug',
'anonymousFormIntakeSlug',
'anonymousFormIntakeCandidates',
'anonymousFormIntakeSlugs',
] as const)('%s is the module function', (name) => {
expect(typeof uiEntry[name]).toBe('function');
});

it('the same bindings, not copies', () => {
expect(uiEntry.publicFormSlug).toBe(publicFormSlug);
expect(uiEntry.anonymousFormIntakeSlug).toBe(anonymousFormIntakeSlug);
expect(uiEntry.anonymousFormIntakeCandidates).toBe(anonymousFormIntakeCandidates);
expect(uiEntry.anonymousFormIntakeSlugs).toBe(anonymousFormIntakeSlugs);
});
});

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();
});

it('publicFormSlug: `/forms/x`, `forms/x`, `x` and extra leading slashes are one slug', () => {
expect(['/forms/x', 'forms/x', 'x', '//forms/x'].map(publicFormSlug)).toEqual(['x', 'x', 'x', 'x']);
});
});

describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs: each of the three shapes alone', () => {
// One row per shape a view carries a form in; each is judged open and then
// withdrawn through either switch.
const SHAPES: Array<[string, (sharing: Record<string, unknown>) => Record<string, unknown>, string | undefined]> = [
['the nested form', (sharing) => ({ name: 'inquiry.default', form: { sharing } }), undefined],
['a formViews entry', (sharing) => ({ name: 'inquiry', formViews: { contact: { sharing } } }), 'contact'],
[
"a viewKind: 'form' item's config",
(sharing) => ({ name: 'inquiry.contact', object: 'inquiry', viewKind: 'form', config: { sharing } }),
'inquiry.contact',
],
];

it.each(SHAPES)('%s: open', (_label, build, key) => {
const view = build(OPEN);
const c = anonymousFormIntakeCandidates(view);
expect(c).toHaveLength(1);
expect(c[0].key).toBe(key);
expect('key' in c[0]).toBe(key !== undefined);
expect(c[0].slug).toBe('contact-us');
expect(c[0].form.sharing).toBe(OPEN);
expect(anonymousFormIntakeSlugs(view)).toEqual(['contact-us']);
});

it.each(SHAPES)('%s: withdrawn through either switch, or with no link', (_label, build) => {
for (const sharing of [{ ...OPEN, enabled: false }, { ...OPEN, allowAnonymous: false }, { enabled: true, allowAnonymous: true }]) {
expect(anonymousFormIntakeCandidates(build(sharing))).toEqual([]);
expect(anonymousFormIntakeSlugs(build(sharing))).toEqual([]);
}
});

it("a config without viewKind: 'form' is not a form", () => {
expect(anonymousFormIntakeCandidates({ name: 'inquiry.grid', viewKind: 'list', config: { sharing: OPEN } })).toEqual([]);
expect(anonymousFormIntakeCandidates({ name: 'inquiry.grid', config: { sharing: OPEN } })).toEqual([]);
});
});

describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs: all three shapes in one body', () => {
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, in that order, 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('view')).toEqual([]);
expect(anonymousFormIntakeSlugs({ formViews: { x: null } })).toEqual([]);
});
});
Loading
Loading