diff --git a/.changeset/22047-spec-ui-anonymous-form-intake.md b/.changeset/22047-spec-ui-anonymous-form-intake.md new file mode 100644 index 00000000000..251ce8161f0 --- /dev/null +++ b/.changeset/22047-spec-ui-anonymous-form-intake.md @@ -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. diff --git a/content/docs/references/ui/sharing.mdx b/content/docs/references/ui/sharing.mdx index d575ea13a37..06bf01903cd 100644 --- a/content/docs/references/ui/sharing.mdx +++ b/content/docs/references/ui/sharing.mdx @@ -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. diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index 936e31b15c1..fb5c77f3230 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -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, @@ -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' }; @@ -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]); + }); +}); diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index f5d1031c4e8..c086718754d 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -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 @@ -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; - /** 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(); -} +// 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 { diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index ea47b1ce2a8..aea96d46494 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -43,6 +43,7 @@ "AddRecordConfig (type)", "AddRecordConfigParsed (type)", "AddRecordConfigSchema (const)", + "AnonymousFormIntakeCandidate (interface)", "App (const)", "App (type)", "AppBranding (type)", @@ -475,6 +476,9 @@ "WidgetColorVariant (type)", "WidgetColorVariantSchema (const)", "actionForm (const)", + "anonymousFormIntakeCandidates (function)", + "anonymousFormIntakeSlug (function)", + "anonymousFormIntakeSlugs (function)", "appForm (const)", "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", @@ -518,6 +522,7 @@ "pageComponentSlotPositions (function)", "pageForm (const)", "partitionAssembledViewArtifacts (function)", + "publicFormSlug (function)", "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 56cbc3db34b..11a38f51a7e 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -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)", @@ -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)", @@ -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)", diff --git a/packages/spec/src/ui/anonymous-form-intake.test.ts b/packages/spec/src/ui/anonymous-form-intake.test.ts new file mode 100644 index 00000000000..9f6c774bc00 --- /dev/null +++ b/packages/spec/src/ui/anonymous-form-intake.test.ts @@ -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]>([ + ['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) => Record, 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) => ({ + 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([]); + }); +}); diff --git a/packages/spec/src/ui/anonymous-form-intake.ts b/packages/spec/src/ui/anonymous-form-intake.ts new file mode 100644 index 00000000000..2056c8f654e --- /dev/null +++ b/packages/spec/src/ui/anonymous-form-intake.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which forms a `view` body opens to anonymous intake — the candidates half of + * the ONE rule. + * + * A form candidate is open to anonymous intake when its `sharing` (the + * `SharingConfigSchema`, `sharing.zod.ts` beside this module) 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, scanned in + * this order: the nested `form`, every `formViews` entry, and the flattened + * `config` of a `viewKind: 'form'` item. + * + * ## Why this half lives in `packages/spec` + * + * `expandViewContainer` (`view.zod.ts`) is the placement precedent: a pure + * helper beside the schema it serves. Prime Directive 2 (no business logic in + * `packages/spec`) holds as ADR-0053 D-D2 reads it: a pure helper that states + * what the contract's own vocabulary denotes is protocol, not business logic, + * and a server package re-exports it. The reason two independent codebases + * must agree on this rule byte for byte is the triage ruling on + * objectui#11545 (`5967405932`): the console derives "published" from the + * server's one rule, never from a hand-copied second one. The server's + * anonymous form doors (`registerFormEndpoints` in `@objectstack/rest`) and the + * write-time judgement of an organization-scoped `view` write + * (`@objectstack/metadata-protocol`) serve exactly the candidates this module + * returns; a console that lists which forms are published imports the same + * functions instead of re-reading the sharing keys. + * `@objectstack/metadata-core` re-exports these bindings (the same functions, + * not a copy), so the server packages keep importing them from there. + * + * ## What stays in `@objectstack/metadata-core` + * + * The halves that read server state: a withdrawal in another metadata layer + * (`anonymousFormIntakeWithdrawnIn`, a kill switch that layering may only + * narrow, never re-open), and whether the deployment's tenancy posture lets an + * open form take an anonymous submission (`anonymousFormIntakeUnavailability`). + * Kept beside them is the object a candidate submits into + * (`anonymousFormObjectName`): a pure read of the form's `data.object` and the + * view's `list.data.object`, `form.data.object` and `object`, which reads no + * server state and stays there because that is where this export's surface + * was drawn. An open candidate here is therefore what the view body itself + * declares, before any other layer or the posture is consulted. + * + * Pure functions with no imports and no module-load work: this module links no + * schema, so a browser bundle that reaches it pays for these functions alone. + */ + +/** 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/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index ab66dc592c1..b62c0021f1c 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -70,6 +70,11 @@ export * from './notification.zod'; // it). `EmbedConfigSchema` / `EmbedConfig` were REMOVED at #5015 per ADR-0049 — // one file, two verdicts. See the block in that module. export * from './sharing.zod'; +// Which forms a `view` body opens to anonymous intake: the candidates half of +// the one rule `SharingConfigSchema` feeds. The server's anonymous form doors +// read these bindings through `@objectstack/metadata-core`, which re-exports +// them, and a console imports them from here instead of re-reading the keys. +export * from './anonymous-form-intake'; // --------------------------------------------------------------------------- // RETIRED in v17 (#4988, ADR-0049 enforce-or-remove): the five interaction diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index bb6f7218757..945b0a62bf6 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -17,8 +17,10 @@ * 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. *