From c47cbdc5bbd0df4711337f5ab0a2257fb6b8a0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:49:36 +0000 Subject: [PATCH 1/4] feat(spec): export the candidates half of the anonymous-form-intake rule from @objectstack/spec/ui publicFormSlug, anonymousFormIntakeSlug, anonymousFormIntakeCandidates, anonymousFormIntakeSlugs and the AnonymousFormIntakeCandidate type move, unchanged in what they decide, from @objectstack/metadata-core into @objectstack/spec/ui beside SharingConfigSchema. metadata-core re-exports the same bindings, so the server callers keep importing them from there and one copy remains; the identity is pinned in its test. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../src/anonymous-form-intake.test.ts | 21 +++ .../src/anonymous-form-intake.ts | 91 +++-------- .../spec/src/ui/anonymous-form-intake.test.ts | 146 ++++++++++++++++++ packages/spec/src/ui/anonymous-form-intake.ts | 103 ++++++++++++ packages/spec/src/ui/index.ts | 5 + packages/spec/src/ui/sharing.zod.ts | 6 +- 6 files changed, 301 insertions(+), 71 deletions(-) create mode 100644 packages/spec/src/ui/anonymous-form-intake.test.ts create mode 100644 packages/spec/src/ui/anonymous-form-intake.ts 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/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..10534fe8594 --- /dev/null +++ b/packages/spec/src/ui/anonymous-form-intake.ts @@ -0,0 +1,103 @@ +// 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` + * + * Same precedent as `expandViewContainer` (`view.zod.ts`): a rule that two + * independent codebases must agree on byte for byte belongs beside the schema + * it serves, so neither end can drift. 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), whether the deployment's tenancy posture lets an open + * form take an anonymous submission (`anonymousFormIntakeUnavailability`), and + * the object a candidate submits into (`anonymousFormObjectName`). 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. * From 3e9a9e48b1264fda58387ac94d4227b8c2430c6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:54:32 +0000 Subject: [PATCH 2/4] chore(spec): regenerate api-surface, export-origins and the sharing reference for the new /ui exports Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- content/docs/references/ui/sharing.mdx | 6 ++++-- packages/spec/api-surface/ui.json | 5 +++++ packages/spec/export-origins/ui.json | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) 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/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)", From cfdc8804f01e33c982be3a33e28e9a248a9cdbe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:00:40 +0000 Subject: [PATCH 3/4] chore(changeset): @objectstack/spec minor, @objectstack/metadata-core patch for the /ui anonymous-form-intake export Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .changeset/22047-spec-ui-anonymous-form-intake.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22047-spec-ui-anonymous-form-intake.md 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..182e0fd6b73 --- /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`), whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`), and which object it submits into (`anonymousFormObjectName`). These read server state and stay in `@objectstack/metadata-core`. A form the new functions call open can still be withheld by those checks. From 6c5741c6b71966e88773503fd0bfb41fe5b0f38d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:00:07 +0000 Subject: [PATCH 4/4] docs(spec): anonymous-form-intake prose: anonymousFormObjectName reads no server state; precedent and ruling attributed correctly The changeset's last bullet and the module's 'What stays' paragraph no longer group anonymousFormObjectName with the halves that read server state, and name only the two checks that can withhold an open form. The 'Why this half lives in packages/spec' paragraph cites expandViewContainer as the placement precedent only, states Prime Directive 2 as ADR-0053 D-D2 reads it, and attributes the byte-for-byte reason to the triage ruling. Comment and changeset text only. Claude-Session: https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7 Co-authored-by: Claude --- .../22047-spec-ui-anonymous-form-intake.md | 2 +- packages/spec/src/ui/anonymous-form-intake.ts | 38 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.changeset/22047-spec-ui-anonymous-form-intake.md b/.changeset/22047-spec-ui-anonymous-form-intake.md index 182e0fd6b73..251ce8161f0 100644 --- a/.changeset/22047-spec-ui-anonymous-form-intake.md +++ b/.changeset/22047-spec-ui-anonymous-form-intake.md @@ -10,4 +10,4 @@ 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`), whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`), and which object it submits into (`anonymousFormObjectName`). These read server state and stay in `@objectstack/metadata-core`. A form the new functions call open can still be withheld by those checks. +- **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/packages/spec/src/ui/anonymous-form-intake.ts b/packages/spec/src/ui/anonymous-form-intake.ts index 10534fe8594..2056c8f654e 100644 --- a/packages/spec/src/ui/anonymous-form-intake.ts +++ b/packages/spec/src/ui/anonymous-form-intake.ts @@ -22,26 +22,34 @@ * * ## Why this half lives in `packages/spec` * - * Same precedent as `expandViewContainer` (`view.zod.ts`): a rule that two - * independent codebases must agree on byte for byte belongs beside the schema - * it serves, so neither end can drift. 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. + * `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), whether the deployment's tenancy posture lets an open - * form take an anonymous submission (`anonymousFormIntakeUnavailability`), and - * the object a candidate submits into (`anonymousFormObjectName`). An open - * candidate here is therefore what the view body itself declares, before any - * other layer or the posture is consulted. + * 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.