diff --git a/.changeset/21476-public-form-intake-advisory.md b/.changeset/21476-public-form-intake-advisory.md new file mode 100644 index 00000000000..2358e3d91af --- /dev/null +++ b/.changeset/21476-public-form-intake-advisory.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/metadata-protocol': patch +'@objectstack/rest': patch +--- + +Public forms on a walled tenancy posture: saving or publishing a view whose public form cannot take anonymous intake now tells the author why, on the response. + +Clause-②: yes (widening) + +On a walled posture (`group` or `isolated` in force), an open public form whose object is walled by an organization column cannot take an anonymous submission: the submission carries no organization, and an insert without one into a walled object is refused. The two anonymous form endpoints already answer such a form as a withdrawn one (`404 FORM_NOT_FOUND`), and the administrator's read of the view (`GET /meta/view/:name`) already states why in `_diagnostics.warnings`. + +- **`@objectstack/metadata-protocol`**: saving the view (`PUT /meta/view/:name`) or publishing its draft (`POST /meta/view/:name/publish`, and a package's batch publish) now answers success with one `warning` advisory per such form, under `advisories`, with rule `public-form-intake-unavailable`. It is located at the form's `sharing` (for example `views[0].formViews.contact.sharing`), its `message` is the same text the administrator's read states, and its `hint` is the remedy: if the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it. The write is never refused. The advisory reads the posture in force from the `tenancy` service, which is what the anonymous endpoints read: a single-posture deployment, a deployment whose walled posture is degraded to `single`, a deployment with no tenancy service, and a form bound to a tenancy-disabled object get no advisory, and a draft save is not judged. The publish refusal for an unstamped platform schedule flow still reads the requested posture, as before. +- **`@objectstack/metadata-core`**: the intake-availability rule moved here from `@objectstack/rest` and is exported, so the anonymous endpoints, the administrator's read and the publish advisory read one answer: `anonymousFormIntakeUnavailability(object, posture, readObjectSchema)` (`null` when the form can take intake, otherwise the object, the posture and the wall column; it judges the object's effective schema, with the injected `organization_id`), `anonymousFormIntakePosture(tenancy)` (the posture in force, as a tenancy service reports it), `anonymousFormIntakeUnavailableMessage` and `anonymousFormIntakeUnavailableRemedy` (the reason and its remedy), `anonymousFormSharingPath` and `anonymousFormObjectName`, and the type `AnonymousFormIntakeUnavailable`. +- **`@objectstack/rest`**: the anonymous form endpoints and the administrator's read import that rule instead of holding their own copy. Their answers are unchanged. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index f94662a984a..1131c999b80 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -478,6 +478,7 @@ orthogonal to both, and no cell here can carry it; it is written out in | Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ | | Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698), row-level-security predicates (#4983) | ✓ | ✓ | ✓ | — | | Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ | +| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ | | Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ | | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ | @@ -594,11 +595,17 @@ The fourth door does not weaken that, because it is held to the CLI's verdicts rather than to its own: a test fails if a rule runs at the runtime publish gate but not on `os build` — the two publish verbs must not disagree. What that column narrows is which *types* it judges, never which *verdict* it reaches. The -one deliberate exception is the platform-schedule row (#6285), runtime-only by ruling: -both of its inputs are facts about the **deployment** (the organization this -write lands in, and whether this deployment walls organizations), and a build -machine's environment is a false signal for them — so `os build` must not judge -it at all. +deliberate exceptions are the two rows whose inputs are facts about the +**deployment**, and a build machine's environment is a false signal for those — +so `os build` must not judge them at all. The platform-schedule row (#6285) is +runtime-only by ruling: its inputs are the organization this write lands in and +whether this deployment walls organizations. The public-form intake row (#21476) +reads the tenancy posture **in force**: on a walled posture, an open public form +whose object is walled by an organization column cannot take an anonymous +submission, so the anonymous form endpoints do not offer it, and a save or +publish of the view answers success with a `public-form-intake-unavailable` +warning in `advisories`, located at the form's `sharing`. It never refuses the +write. Some rows are deliberately not universal across the three commands, and each is one-directional (none lets a stack through a gate another command enforces): diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 893eb2d7704..4761e1b6fca 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -100,6 +100,7 @@ export default defineView({ > - 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.enabled === true` and `sharing.allowAnonymous === true` are exposed. +> - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again. ## 2. (Optional) Create the `guest_portal` permission set diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index 24c9a89cb89..ed6e3c81dc4 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -4,8 +4,14 @@ import { describe, it, expect } from 'vitest'; import { SharingConfigSchema } from '@objectstack/spec/ui'; import { anonymousFormIntakeCandidates, + anonymousFormIntakePosture, anonymousFormIntakeSlug, anonymousFormIntakeSlugs, + anonymousFormIntakeUnavailability, + anonymousFormIntakeUnavailableMessage, + anonymousFormIntakeUnavailableRemedy, + anonymousFormObjectName, + anonymousFormSharingPath, publicFormSlug, } from './anonymous-form-intake.js'; @@ -79,3 +85,102 @@ describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs — the three expect(publicFormSlug('//forms/x')).toBe('x'); }); }); + +// [#21476] Whether an open form can take an anonymous submission on this +// posture — the one predicate both anonymous doors, the admin read and the +// runtime authoring gate's advisory read. +describe('anonymousFormIntakeUnavailability — the intake-availability predicate', () => { + /** The object as a served document carries it: the registry injects `organization_id`. */ + const served = (extra: Record = {}) => ({ + name: 'inquiry', + fields: { organization_id: { type: 'lookup', reference: 'sys_organization' }, email: { type: 'text' } }, + ...extra, + }); + /** The same object as a stored or pending body carries it: declared fields only. */ + const raw = (extra: Record = {}) => ({ name: 'inquiry', fields: { email: { type: 'text' } }, ...extra }); + + it.each(['isolated', 'group'] as const)("'%s': a walled object is unavailable, naming the object, the posture and the column", (posture) => { + expect(anonymousFormIntakeUnavailability('inquiry', posture, () => served())) + .toEqual({ object: 'inquiry', posture, tenantField: 'organization_id' }); + }); + + it('judges the EFFECTIVE schema: a stored body with no declared organization_id is walled all the same', () => { + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => raw())) + .toEqual({ object: 'inquiry', posture: 'isolated', tenantField: 'organization_id' }); + }); + + it('a declared tenancy.tenantField the object really has is the column named', () => { + const schema = served({ tenancy: { tenantField: 'company_id' }, fields: { company_id: { type: 'text' } } }); + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema)?.tenantField).toBe('company_id'); + }); + + it.each<[string, unknown]>([ + ['tenancy: { enabled: false } (ADR-0066)', served({ tenancy: { enabled: false } })], + ['an object the universe does not hold', undefined], + ['an object with no fields record and no wall', { name: 'inquiry', systemFields: false }], + ])('CONTROL, walled posture — %s: available', (_label, schema) => { + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema)).toBeNull(); + }); + + it.each<[string, 'single' | undefined]>([ + ["the 'single' posture", 'single'], + ['no tenancy service (no posture)', undefined], + ])('CONTROL — %s: available, and the object is never read', (_label, posture) => { + let reads = 0; + expect(anonymousFormIntakeUnavailability('inquiry', posture, () => { reads += 1; return served(); })).toBeNull(); + expect(reads).toBe(0); + }); + + it('an asynchronous reader gets a promise when a wall is in force, and null without reading otherwise', async () => { + const walled = anonymousFormIntakeUnavailability('inquiry', 'group', async () => served()); + expect(walled).toBeInstanceOf(Promise); + expect(await walled).toEqual({ object: 'inquiry', posture: 'group', tenantField: 'organization_id' }); + expect(anonymousFormIntakeUnavailability('inquiry', 'single', async () => served())).toBeNull(); + }); +}); + +describe('anonymousFormIntakePosture — the posture IN FORCE, as the tenancy service reports it', () => { + it('reads `posture`, never `requestedPosture`: a degraded walled request is single', () => { + expect(anonymousFormIntakePosture({ posture: 'single', requestedPosture: 'isolated' })).toBe('single'); + expect(anonymousFormIntakePosture({ posture: 'group' })).toBe('group'); + expect(anonymousFormIntakePosture({ posture: 'multi' })).toBe('isolated'); + }); + + it('no service, or no recognisable posture: undefined', () => { + for (const tenancy of [undefined, null, {}, { posture: 'walled' }, 'isolated']) { + expect(anonymousFormIntakePosture(tenancy)).toBeUndefined(); + } + }); +}); + +describe('where the reason is located, and the reason itself', () => { + it('anonymousFormSharingPath: form.sharing, formViews.KEY.sharing, config.sharing', () => { + const view = { + name: 'inquiry.contact', + form: { sharing: { ...OPEN, publicLink: '/forms/nested' } }, + formViews: { contact: { sharing: { ...OPEN, publicLink: '/forms/a' } } }, + viewKind: 'form', + config: { sharing: { ...OPEN, publicLink: '/forms/flat' } }, + }; + expect(anonymousFormIntakeCandidates(view).map((c) => anonymousFormSharingPath(view, c))) + .toEqual(['form.sharing', 'formViews.contact.sharing', 'config.sharing']); + }); + + it('anonymousFormObjectName: the form\'s own data.object first, then the view\'s', () => { + const view = { object: 'v_obj', list: { data: { object: 'list_obj' } } }; + expect(anonymousFormObjectName(view, { data: { object: 'form_obj' } })).toBe('form_obj'); + expect(anonymousFormObjectName(view, {})).toBe('list_obj'); + expect(anonymousFormObjectName({ object: 'v_obj' }, {})).toBe('v_obj'); + expect(anonymousFormObjectName(undefined, undefined)).toBeUndefined(); + }); + + it('the message names the slug, the object, the column and the posture, and ends with the remedy', () => { + const u = { object: 'inquiry', posture: 'isolated' as const, tenantField: 'organization_id' }; + const message = anonymousFormIntakeUnavailableMessage('contact-us', u); + for (const named of ["'/forms/contact-us'", "'inquiry'", "'organization_id'", "'isolated'"]) { + expect(message).toContain(named); + } + expect(anonymousFormIntakeUnavailableRemedy(u)).toContain('tenancy: { enabled: false }'); + expect(message.endsWith(` ${anonymousFormIntakeUnavailableRemedy(u)}`)).toBe(true); + }); +}); diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index bf5a099a48b..c347eb75d5d 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -25,8 +25,25 @@ * 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 + * anonymous doors, the administrator's read of the view, and the runtime + * authoring gate's save/publish advisory — and each states the same reason + * ({@link anonymousFormIntakeUnavailableMessage}) at the same location + * ({@link anonymousFormSharingPath}), so the author is told exactly when the + * doors withhold the form. */ +import { + normalizeTenancyPosture, + postureEnforcesWall, + type TenancyPosture, +} from '@objectstack/spec/security'; +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`). */ @@ -81,3 +98,145 @@ export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntak export function anonymousFormIntakeSlugs(view: unknown): string[] { return [...new Set(anonymousFormIntakeCandidates(view).map((c) => c.slug))].sort(); } + +/** 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 { + const v = (view && typeof view === 'object' ? view : {}) as Record; + const f = (form && typeof form === 'object' ? form : {}) as Record; + return f.data?.object ?? v.list?.data?.object ?? v.form?.data?.object ?? v.object; +} + +/** + * Where a candidate's `sharing` sits in the `view` body — `form.sharing`, + * `formViews.KEY.sharing` or `config.sharing`. Item-relative: the admin read + * states it as is, and the authoring gate prefixes the write's own root. + */ +export function anonymousFormSharingPath(view: Record, candidate: AnonymousFormIntakeCandidate): string { + if (candidate.form === view.form) return 'form.sharing'; + if (candidate.key !== undefined && view.formViews?.[candidate.key] === candidate.form) { + return `formViews.${candidate.key}.sharing`; + } + return 'config.sharing'; +} + +/** [#21476] Why an open public form cannot take an anonymous submission on this deployment. */ +export interface AnonymousFormIntakeUnavailable { + /** The object the form submits into. */ + object: string; + /** The walled posture in force. */ + posture: TenancyPosture; + /** The column the object is walled by. */ + tenantField: string; +} + +/** + * [#21476] The posture IN FORCE, as a `tenancy` service reports it, or + * `undefined` when there is no tenancy service (or it names no posture). + * + * In force, never requested: a walled request the deployment cannot enforce + * is `single` there (ADR-0105 D12), and that is the value SecurityPlugin hands + * the engine (`setTenancyPostureProvider`) — so it is what decides whether an + * anonymous insert is refused. Never re-read from the environment. Every + * reader of {@link anonymousFormIntakeUnavailability} reads the posture here, + * so the doors and the authoring gate cannot read two different postures. + */ +export function anonymousFormIntakePosture(tenancy: unknown): TenancyPosture | undefined { + if (!tenancy || typeof tenancy !== 'object') return undefined; + return normalizeTenancyPosture((tenancy as { posture?: unknown }).posture); +} + +const isPromiseLike = (value: unknown): value is PromiseLike => + !!value && (typeof value === 'object' || typeof value === 'function') + && typeof (value as { then?: unknown }).then === 'function'; + +/** The predicate's second fact, once the object schema is in hand. */ +function judgeObjectSchema( + object: string, + posture: TenancyPosture, + objectSchema: unknown, +): AnonymousFormIntakeUnavailable | null { + // The EFFECTIVE schema: the declared fields plus the columns the platform + // injects (`organization_id` among them). A served object document already + // carries them, so this is the same reference there; a stored or pending + // body does not, and judged raw it would read as unwalled. + const effective = applyInjectedSystemColumns(objectSchema); + const fields = (effective as { fields?: unknown } | null | undefined)?.fields; + const tenantField = resolveRecordWallOrganizationField( + effective, + (field) => !!fields && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, field), + ); + return tenantField === null ? null : { object, posture, tenantField }; +} + +/** + * [#21476] THE intake-availability predicate: `null` when an open public form + * bound to `object` can take an anonymous submission on `posture`, otherwise + * why it cannot. + * + * An anonymous submission carries no organization, and on a walled posture the + * engine refuses an insert without one into an object walled by an organization + * column (`resolveSystemInsertOrganization`, `@objectstack/objectql`). Its two + * facts: + * + * - `posture`: {@link anonymousFormIntakePosture}, the posture in force. + * `undefined` (no tenancy service) names no wall. + * - the wall column: `resolveRecordWallOrganizationField` over the object's + * EFFECTIVE schema (its declared fields plus the injected columns, as a + * served object document carries them). + * + * `readObjectSchema` runs only once a wall is in force. A synchronous reader + * gets a synchronous answer (the authoring gate); a reader that returns a + * promise gets a promise, or `null` when nothing had to be read — `await` + * reads both (the doors and the admin read). + * + * ⚠️ It reads declarations. The engine also passes a federated (`external`) + * object, a platform object its inventory has not admitted, and a row a + * `beforeInsert` hook stamped; a form bound to one of those with a wall column + * is withheld although the engine would accept it (fail closed). + */ +export function anonymousFormIntakeUnavailability( + object: string, + posture: TenancyPosture | undefined, + readObjectSchema: () => PromiseLike, +): Promise | null; +export function anonymousFormIntakeUnavailability( + object: string, + posture: TenancyPosture | undefined, + readObjectSchema: () => unknown, +): AnonymousFormIntakeUnavailable | null; +export function anonymousFormIntakeUnavailability( + object: string, + posture: TenancyPosture | undefined, + readObjectSchema: () => unknown, +): Promise | AnonymousFormIntakeUnavailable | null { + if (posture === undefined || !postureEnforcesWall(posture)) return null; + const objectSchema = readObjectSchema(); + if (isPromiseLike(objectSchema)) { + return Promise.resolve(objectSchema).then((schema) => judgeObjectSchema(object, posture, schema)); + } + return judgeObjectSchema(object, posture, objectSchema); +} + +/** [#21476] How the author makes an unavailable form available again: the closing sentences of the reason. */ +export function anonymousFormIntakeUnavailableRemedy(u: AnonymousFormIntakeUnavailable): string { + return ( + `If the rows of '${u.object}' belong to no organization, declare that on the object ` + + `(tenancy: { enabled: false }) and the form is offered again. Otherwise collect this data through ` + + `a signed-in surface.` + ); +} + +/** + * [#21476] THE reason, with its remedy: what the administrator's read states at + * the form's `sharing`, and what the authoring gate's advisory states on save + * and publish — one string, so the two can never word it differently. + */ +export function anonymousFormIntakeUnavailableMessage(slug: string, u: AnonymousFormIntakeUnavailable): string { + return ( + `Public form '/forms/${slug}' is not offered to anonymous visitors on this deployment, so both ` + + `anonymous form doors answer it as not found. It submits into '${u.object}', which is walled by ` + + `'${u.tenantField}', and this deployment runs the '${u.posture}' tenancy posture: an anonymous ` + + `submission carries no organization, and an insert without one into a walled object is refused. ` + + anonymousFormIntakeUnavailableRemedy(u) + ); +} diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index c63e35b4825..b81463197f0 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -30,7 +30,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // would close a dependency cycle turbo rejects outright — which is why all 26 // of this package's (file, verb) pairs sat in the gate's DEBT ledger until // #5619 sank the two predicates into a package both sides already depend on. -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, + // [#21476] The reason the admin read of a view states — the advisory must carry the same bytes. + anonymousFormIntakeUnavailableMessage, +} from '@objectstack/metadata-core'; // [#4716] The advisory-tier rule the Q2 fence test proves its body WOULD trip // — imported from the full barrel deliberately: this is a TEST, not the gate // (the gate itself may only reach the registry through `@objectstack/lint/runtime`, @@ -39,7 +45,7 @@ import { validateSemanticRoles } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from './protocol.js'; import type { MetadataAuthoringChannel } from './protocol.js'; import { SDUI_MANIFEST_SERVICE } from './index.js'; -import { stampHtmlPageRequires } from './runtime-authoring-gate.js'; +import { PUBLIC_FORM_INTAKE_UNAVAILABLE, stampHtmlPageRequires } from './runtime-authoring-gate.js'; /** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */ const brokenApprovalFlow = () => ({ @@ -1454,3 +1460,131 @@ describe('stored html page `requires` at load and at draft promotion (#20312)', expect(storedPage(rows)?.requires).toEqual(['plugin-absent']); }); }); + +/** + * [#21476] The public-form intake advisory, end to end through the two write + * doors: `saveMetaItem` (REST `PUT /meta/view/:name`) and the draft → active + * promotion `publishMetaItem` (REST `POST /meta/view/:name/publish`). + * + * The posture is read off a `tenancy` service in the protocol's own services + * table — the service, and the reader (`anonymousFormIntakePosture`), the + * anonymous form doors read. The rows pin both halves: on a walled posture in + * force the write SUCCEEDS with exactly one warning, located at the form's + * `sharing` and carrying the admin read's reason byte for byte; every control + * the doors serve the form on raises nothing, the degraded deployment included + * — whose REQUESTED posture (`OS_TENANCY_POSTURE`) walls while its in-force + * posture does not. + */ +describe('public-form intake advisory on save and publish (#21476)', () => { + const SLUG = 'contact-us'; + const NAME = 'showcase_inquiry'; + /** The showcase's contact form: a container whose public form is `formViews.contact`. */ + const contactContainer = () => ({ + list: { type: 'grid', data: { provider: 'object', object: NAME }, columns: [{ field: 'name' }] }, + formViews: { + contact: { + type: 'simple', + data: { provider: 'object', object: NAME }, + sections: [{ name: 'about', fields: [{ field: 'name' }, { field: 'email' }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + }, + }, + }); + /** The bound object as the live registry holds it — `organization_id` injected at registration. */ + const inquiry = (tenancyDisabled: boolean) => ({ + name: NAME, + label: 'Inquiry', + ...(tenancyDisabled ? { tenancy: { enabled: false } } : {}), + fields: { + organization_id: { type: 'lookup', reference: 'sys_organization' }, + name: { type: 'text', label: 'Name' }, + email: { type: 'email', label: 'Email' }, + }, + }); + + type Tenancy = 'isolated' | 'group' | 'degraded' | 'single' | 'no-service'; + /** A `tenancy` service as plugin-auth registers it: `posture` is the posture IN FORCE. */ + const tenancyService = (t: Exclude) => ({ + posture: t === 'degraded' ? 'single' : t, + requestedPosture: t === 'degraded' ? 'isolated' : t, + defaultOrgId: async () => (t === 'single' ? 'org_alpha' : null), + }); + + function hostOn(tenancy: Tenancy, tenancyDisabled = false) { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? [inquiry(tenancyDisabled)] : []); + const services = new Map( + tenancy === 'no-service' ? [] : [['tenancy', tenancyService(tenancy)]], + ); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_test') as any; + return { protocol, rows }; + } + + const put = (protocol: any, extra: Record = {}) => + protocol.saveMetaItem({ type: 'view', name: NAME, item: contactContainer(), ...extra }); + const publish = async (protocol: any) => { + await expect(put(protocol, { mode: 'draft' })).resolves.toMatchObject({ success: true }); + return protocol.publishMetaItem({ type: 'view', name: NAME }); + }; + const intake = (response: { advisories?: Array<{ rule: string }> }) => + (response.advisories ?? []).filter((a) => a.rule === PUBLIC_FORM_INTAKE_UNAVAILABLE); + + let warn: ReturnType; + const savedPosture = process.env.OS_TENANCY_POSTURE; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + }); + + for (const posture of ['isolated', 'group'] as const) { + for (const [door, write] of [['PUT', put], ['publish', publish]] as const) { + it(`'${posture}' in force, walled object — ${door} succeeds with exactly one warning, the admin read's reason at the form's sharing`, async () => { + const { protocol, rows } = hostOn(posture); + const response = await write(protocol); + expect(response.success).toBe(true); + expect(response.advisories).toEqual([{ + severity: 'warning', + rule: PUBLIC_FORM_INTAKE_UNAVAILABLE, + where: `view "${NAME}" · public form "/forms/${SLUG}"`, + path: 'views[0].formViews.contact.sharing', + message: anonymousFormIntakeUnavailableMessage(SLUG, { + object: NAME, posture, tenantField: 'organization_id', + }), + hint: expect.stringContaining('tenancy: { enabled: false }'), + }]); + // Never a refusal: the row landed active. + expect([...rows.values()].filter((r) => r.type === 'view' && r.state === 'active')).toHaveLength(1); + }); + } + } + + it.each<[string, Tenancy, boolean]>([ + ['walled posture, object declared tenancy: { enabled: false }', 'isolated', true], + ["the 'single' posture", 'single', false], + ['no tenancy service registered', 'no-service', false], + ])('CONTROL — %s: PUT and publish raise no intake advisory', async (_label, tenancy, tenancyDisabled) => { + const { protocol } = hostOn(tenancy, tenancyDisabled); + const saved = await put(protocol); + expect(saved.success).toBe(true); + expect(intake(saved)).toEqual([]); + const published = await publish(protocol); + expect(published.success).toBe(true); + expect(intake(published)).toEqual([]); + }); + + it('CONTROL — a degraded walled deployment: the REQUESTED posture walls, the posture in force does not; the doors serve, so nothing is raised', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const { protocol } = hostOn('degraded'); + const saved = await put(protocol); + expect(saved.success).toBe(true); + expect(intake(saved)).toEqual([]); + const published = await publish(protocol); + expect(published.success).toBe(true); + expect(intake(published)).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 24cd0629f98..c5570902c5a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9,7 +9,7 @@ import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, res // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering // this question with it is a bug (cloud#1020, #5233) — so the posture, and only // the posture, is what the runtime authoring gate is told. -import { postureEnforcesWall } from '@objectstack/spec/security'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; // [commit 376c70f98] The derived `version` this file's `getDiscovery()` serves as the // `DiscoverySchema` "System Identity" field — never a literal again. import { resolveDiscoveryVersion } from './discovery-version.js'; @@ -92,6 +92,10 @@ import { // 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, + // [#21476] The posture IN FORCE, read off the `tenancy` service the one way + // the anonymous form doors read it — the runtime authoring gate's input for + // its public-form intake advisory (see `tenancyPostureInForce()`). + anonymousFormIntakePosture, } 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 @@ -5662,6 +5666,10 @@ export class ObjectStackProtocolImplementation implements // #6285 kind, read here per publish and passed in so the gate stays pure. const sduiManifest = this.resolveSduiManifest(); + // [#21476] The tenancy posture in force — a host fact of the same kind, + // read here per publish and passed in so the gate stays pure. + const tenancyPostureInForce = this.tenancyPostureInForce(); + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -5677,6 +5685,10 @@ export class ObjectStackProtocolImplementation implements ...(packageScope !== undefined ? { packageScope } : {}), ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), + // [#21476] The posture IN FORCE, for the public-form intake + // advisory — a separate input from the requested one above, on + // purpose: see `tenancyPostureInForce()`. + ...(tenancyPostureInForce !== undefined ? { tenancyPostureInForce } : {}), ...(engineJudge !== undefined ? { judgeFilter: engineJudge } : {}), ...(restoredCredentialPaths !== undefined ? { restoredCredentialPaths } : {}), // [#20312] The deployment's component manifest, read per publish; @@ -6010,6 +6022,40 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#21476] The tenancy posture IN FORCE, as this kernel's `tenancy` service + * reports it (`anonymousFormIntakePosture`, `@objectstack/metadata-core`) — + * the runtime authoring gate's input for its public-form intake advisory. + * `undefined` when no tenancy service is registered. + * + * The doors that advisory speaks for read exactly this: both anonymous form + * doors in `@objectstack/rest` ask the same service through the same + * reader, and it is the posture SecurityPlugin hands the engine. So a + * DEGRADED walled deployment (a wall requested and not enforceable, which + * the service reports as `single`) gets no advisory, because its doors do + * serve the form and its engine does take the insert. + * + * ⛔ It does NOT replace {@link orgWallEnforced}. That input is the + * REQUESTED posture, read fail-closed: #6155 Q3=A names + * `postureEnforcesWall(resolveTenancyPosture())` as the #6285 refusal's + * input verbatim, and an unrecognized `OS_TENANCY_POSTURE` reads as walled + * (ADR-0105). Feeding that refusal this reading instead would narrow it — + * off on a degraded deployment, on a composition with no tenancy service, + * and on an unrecognized posture value — which is a ruling's to make, not + * an advisory's. Two rules, two questions, two inputs. + * + * Read per publish, as {@link resolveSduiManifest} reads its service, and + * never allowed to fail the write: a service whose posture cannot be read + * reports none, and the advisory is simply not raised. + */ + private tenancyPostureInForce(): TenancyPosture | undefined { + try { + return anonymousFormIntakePosture(this.getServicesRegistry?.().get('tenancy')); + } catch { + return undefined; + } + } + /** * Run the registered projector for a just-persisted mutation (ADR-0094). * Returns `undefined` when no projector is registered for the type; diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.public-form-intake.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.public-form-intake.test.ts new file mode 100644 index 00000000000..aa14f0c0ba7 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.public-form-intake.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21476 — the gate-local advisory for an open public form this deployment's + * posture cannot take anonymous intake for. + * + * Both anonymous form doors withhold such a form, and the administrator's read + * of the view states why (`@objectstack/rest`). This is the publish half: the + * author who saves or publishes the form is told on the 2xx, as ONE `warning` + * advisory located at the form's `sharing` and carrying the admin read's + * reason — the same `@objectstack/metadata-core` export, so the same bytes. + * + * Harness: none. `evaluateRuntimeAuthoringGate` is pure — the posture in force + * and the object universe arrive as arguments. The end-to-end rows through + * `saveMetaItem` / `publishMetaItem` and a real `tenancy` service are in + * `protocol.runtime-authoring-gate.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { + anonymousFormIntakeUnavailableMessage, + anonymousFormIntakeUnavailableRemedy, +} from '@objectstack/metadata-core'; + +import { + evaluateRuntimeAuthoringGate, + PUBLIC_FORM_INTAKE_UNAVAILABLE, +} from './runtime-authoring-gate.js'; + +const SLUG = 'contact-us'; +const SHARING = { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }; + +/** The object as the gate's live universe carries it: the registry injected `organization_id`. */ +const inquiry = (extra: Record = {}) => ({ + name: 'showcase_inquiry', + label: 'Inquiry', + fields: { + organization_id: { type: 'lookup', reference: 'sys_organization' }, + name: { type: 'text', label: 'Name' }, + email: { type: 'email', label: 'Email' }, + }, + ...extra, +}); + +/** The showcase's contact form: a container whose public form is `formViews.contact`. */ +const contactContainer = (sharing: Record = SHARING) => ({ + list: { type: 'grid', data: { provider: 'object', object: 'showcase_inquiry' }, columns: [{ field: 'name' }] }, + formViews: { + contact: { + type: 'simple', + data: { provider: 'object', object: 'showcase_inquiry' }, + sections: [{ name: 'about', fields: [{ field: 'name' }, { field: 'email' }] }], + sharing, + }, + }, +}); + +const judge = (over: Partial[0]> = {}) => + evaluateRuntimeAuthoringGate({ + type: 'view', + name: 'showcase_inquiry', + state: 'active', + body: contactContainer(), + objects: [inquiry()], + tenancyPostureInForce: 'isolated', + ...over, + }); + +const intakeAdvisories = (verdict: ReturnType) => + verdict.advisories.filter((a) => a.rule === PUBLIC_FORM_INTAKE_UNAVAILABLE); + +describe('#21476 — public-form intake advisory (pure gate)', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`'${posture}', walled object: exactly one warning, at the form's sharing, with the admin read's reason`, () => { + const verdict = judge({ tenancyPostureInForce: posture }); + expect(verdict.error).toBeNull(); + expect(verdict.advisories).toHaveLength(1); + const facts = { object: 'showcase_inquiry', posture, tenantField: 'organization_id' }; + expect(verdict.advisories[0]).toEqual({ + severity: 'warning', + rule: PUBLIC_FORM_INTAKE_UNAVAILABLE, + where: `view "showcase_inquiry" · public form "/forms/${SLUG}"`, + path: 'views[0].formViews.contact.sharing', + message: anonymousFormIntakeUnavailableMessage(SLUG, facts), + hint: anonymousFormIntakeUnavailableRemedy(facts), + }); + }); + } + + it('locates each form shape at its own sharing: nested form, flattened form item', () => { + const nested = { form: { data: { object: 'showcase_inquiry' }, sharing: SHARING } }; + expect(intakeAdvisories(judge({ body: nested })).map((a) => a.path)).toEqual(['views[0].form.sharing']); + const flat = { name: 'showcase_inquiry.contact', viewKind: 'form', object: 'showcase_inquiry', config: { sharing: SHARING } }; + expect(intakeAdvisories(judge({ name: 'showcase_inquiry.contact', body: flat })).map((a) => a.path)) + .toEqual(['views[0].config.sharing']); + }); + + it('a pending object in the same batch is judged by its EFFECTIVE schema (no declared organization_id)', () => { + const pendingRaw = { name: 'showcase_inquiry', fields: { name: { type: 'text' } } }; + const verdict = judge({ objects: [], pending: { objects: [pendingRaw], permissions: [], books: [], datasets: [] } }); + expect(intakeAdvisories(verdict)).toHaveLength(1); + }); + + it.each<[string, Partial[0]>]>([ + ['an object declared tenancy: { enabled: false }', { objects: [inquiry({ tenancy: { enabled: false } })] }], + ["the 'single' posture (a degraded walled request reads single in force)", { tenancyPostureInForce: 'single' }], + ['no tenancy service (no posture in force)', { tenancyPostureInForce: undefined }], + ['a form withdrawn from anonymous intake', { body: contactContainer({ ...SHARING, allowAnonymous: false }) }], + ['a draft save (drafts are never gated, #4463 D1)', { state: 'draft' }], + ['a non-view write', { type: 'page', body: { name: 'landing', kind: 'html', source: '
' } }], + ])('CONTROL — %s: no advisory', (_label, over) => { + const verdict = judge(over); + expect(verdict.error).toBeNull(); + expect(intakeAdvisories(verdict)).toEqual([]); + }); + + it('the requested posture does not move it: orgWallEnforced true with single in force raises nothing', () => { + expect(intakeAdvisories(judge({ orgWallEnforced: true, tenancyPostureInForce: 'single' }))).toEqual([]); + }); + + it('a refused view write discloses the rule among the rules that ran', () => { + // A flattened list overlay sorting by a field the object does not have: + // refused by the shared sort rule, so the verdict carries `rulesRun`. + const verdict = judge({ + name: 'showcase_inquiry.custom', + body: { + name: 'showcase_inquiry.custom', object: 'showcase_inquiry', viewKind: 'list', type: 'grid', + columns: ['name'], sort: [{ field: 'amout', order: 'desc' }], + }, + }); + const err = verdict.error as { code?: string; status?: number; rulesRun?: string[] } | null; + expect([err?.code, err?.status]).toEqual(['INVALID_METADATA', 422]); + expect(err?.rulesRun).toContain(PUBLIC_FORM_INTAKE_UNAVAILABLE); + }); +}); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 88b97ae4949..0875866b09c 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -76,6 +76,20 @@ import { } from '@objectstack/sdui-parser'; import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { TenancyPosture } from '@objectstack/spec/security'; +// [#21476] The ONE answer to "can this open public form take anonymous intake +// on this posture, and why not" — the same export both anonymous form doors and +// the administrator's read of the view call (`@objectstack/rest`), so the +// advisory below is raised exactly when the doors withhold the form, at the +// location and in the words the admin read uses. +import { + anonymousFormIntakeCandidates, + anonymousFormIntakeUnavailability, + anonymousFormIntakeUnavailableMessage, + anonymousFormIntakeUnavailableRemedy, + anonymousFormObjectName, + anonymousFormSharingPath, +} from '@objectstack/metadata-core'; /** * The structured issue shape a 422 carries — D3's "reuse the Zod envelope". @@ -353,6 +367,95 @@ export function findPlatformScheduleOrgGaps(args: { return issues; } +// ───────────────────────────────────────────────────────────────────────────── +// #21476 — the save/publish advisory for an open public form this deployment's +// posture cannot take anonymous intake for. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A `view` opens a form to anonymous intake, and on this deployment's posture + * the object it submits into cannot take an anonymous submission. + * + * ## What the author is told, and why on this channel + * + * An anonymous submission carries no organization, and on a walled posture the + * engine refuses an insert without one into an object walled by an organization + * column. So both anonymous form doors withhold such a form (they answer it as + * a withdrawn form), and the administrator's read of the view states why. This + * is the third reader of the same answer: the author who SAVES or PUBLISHES the + * form is told on the response the write earns, before any visitor meets the + * not-found. One predicate, `anonymousFormIntakeUnavailability` + * (`@objectstack/metadata-core`), decides for all three, and the advisory's + * `message` is the admin read's reason byte for byte. + * + * ## Why a warning, never a refusal + * + * The write is legitimate: the form is valid metadata, and it becomes servable + * the moment the posture or the object's tenancy changes. Refusing it would + * make a deployment fact block an authoring act — the triage ruling asks for + * the reason to be stated, located and named, and nothing more. + * + * ## Why it is gate-local, beside the #6285 rule + * + * Its input is a fact about the DEPLOYMENT (the posture in force), which a + * build machine cannot know, so `AUTHORING_RULES` must not judge it — the + * reason {@link findPlatformScheduleOrgGaps} lives here (#6155 Q3=A). + */ +export const PUBLIC_FORM_INTAKE_UNAVAILABLE = 'public-form-intake-unavailable'; + +/** + * Judge one about-to-be-published `view` body: one `warning` per open public + * form whose object cannot take anonymous intake on `tenancyPostureInForce`. + * + * PURE — the posture arrives as an argument and the object schemas come from + * the resolution universe the gate already holds (`objects`, the live universe + * plus this batch's pending drafts). It reads no service, no store and no + * environment. + * + * The location is the form's `sharing` as `anonymousFormSharingPath` places it, + * under the write's own root: a `view` write is the sole member of its + * snapshot collection, so `views[0]` IS this write (`RuntimeAuthoringIssue.path`). + */ +export function findPublicFormIntakeGaps(args: { + /** Singular metadata type of the item being written. */ + type: string; + /** Metadata name, for the diagnostic `where`. */ + name: string; + /** The body as it will be persisted. */ + body: unknown; + /** The object declarations a form's target resolves against. */ + objects: readonly unknown[]; + /** The tenancy posture IN FORCE (`anonymousFormIntakePosture`); absent = no tenancy service. */ + tenancyPostureInForce?: TenancyPosture; +}): RuntimeAuthoringIssue[] { + if (args.type !== 'view' || !isRec(args.body)) return []; + const view = args.body; + const candidates = anonymousFormIntakeCandidates(view); + if (candidates.length === 0) return []; + + const viewName = typeof view.name === 'string' && view.name ? view.name : args.name; + const issues: RuntimeAuthoringIssue[] = []; + for (const candidate of candidates) { + const object = anonymousFormObjectName(view, candidate.form); + if (!object) continue; + const unavailable = anonymousFormIntakeUnavailability( + object, + args.tenancyPostureInForce, + (): unknown => args.objects.find((o) => isRec(o) && o.name === object), + ); + if (!unavailable) continue; + issues.push({ + severity: 'warning', + rule: PUBLIC_FORM_INTAKE_UNAVAILABLE, + where: `view "${viewName}" · public form "/forms/${candidate.slug}"`, + path: `views[0].${anonymousFormSharingPath(view, candidate)}`, + message: anonymousFormIntakeUnavailableMessage(candidate.slug, unavailable), + hint: anonymousFormIntakeUnavailableRemedy(unavailable), + }); + } + return issues; +} + // ───────────────────────────────────────────────────────────────────────────── // #10377 — the batch's OWN pending drafts are part of the closure it is judged // against. @@ -886,6 +989,19 @@ export function evaluateRuntimeAuthoringGate(args: { * every call site that can know the answer states it. */ orgWallEnforced?: boolean; + /** + * [#21476] The tenancy posture IN FORCE — what the `tenancy` service reports + * (`anonymousFormIntakePosture`), the value the anonymous form doors and the + * engine read. Absent ⇒ no tenancy service, so no wall to judge against. + * + * ⛔ Deliberately NOT {@link orgWallEnforced}, and the two are not to be + * merged. That input is the REQUESTED posture (#6155 Q3=A names + * `postureEnforcesWall(resolveTenancyPosture())` verbatim), read fail-closed, + * and it arms a refusal. This one feeds an advisory that must agree with + * what the doors do, and the doors read the posture in force: on a degraded + * walled deployment the two differ, and each is the input its rule names. + */ + tenancyPostureInForce?: TenancyPosture; /** * [#20158] The host engine's judge-only filter admission * (`IObjectQLEngine.judgeFilter`, #19995 ruling C), BOUND to that engine. @@ -920,6 +1036,10 @@ export function evaluateRuntimeAuthoringGate(args: { // No rules ran, so there is nothing to report on either half. if (args.state !== 'active') return { error: null, advisories: [] }; + // The object universe, folded once: the shared rules resolve names against + // it and the #21476 rule reads a form's target object out of it. + const objects = mergePendingDeclarations(args.objects ?? [], args.pending?.objects); + const result = runRuntimeAuthoringRules({ type: args.type, item: args.body, @@ -931,7 +1051,7 @@ export function evaluateRuntimeAuthoringGate(args: { // had been threaded, `datasets` had not, and the difference was // invisible until an error-severity rule landed on the un-threaded one. context: { - objects: mergePendingDeclarations(args.objects ?? [], args.pending?.objects), + objects, permissions: mergePendingDeclarations(args.permissions ?? [], args.pending?.permissions), books: mergePendingDeclarations(args.books ?? [], args.pending?.books), datasets: mergePendingDeclarations(args.datasets ?? [], args.pending?.datasets), @@ -970,6 +1090,19 @@ export function evaluateRuntimeAuthoringGate(args: { body: args.body, ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), }); + // [#21476] The gate-local advisory for an open public form this posture + // cannot take anonymous intake for. Warning-only by construction, so it + // joins the advisory half and never the refusal: the write lands, and the + // author reads why the anonymous doors withhold the form on the response. + const publicFormIntakeGaps = findPublicFormIntakeGaps({ + type: args.type, + name: args.name, + body: args.body, + objects, + ...(args.tenancyPostureInForce !== undefined + ? { tenancyPostureInForce: args.tenancyPostureInForce } + : {}), + }); const localIssues = [ ...scheduleOrgGaps, ...(pageSourceFindings ?? []).filter((f) => f.severity === 'error').map(toIssue), @@ -977,6 +1110,7 @@ export function evaluateRuntimeAuthoringGate(args: { const advisoryFindings = [ ...result.advisories, ...(pageSourceFindings ?? []).filter((f) => f.severity !== 'error'), + ...publicFormIntakeGaps, ]; // [#4717] The advisory half of D3, now with somewhere to go. The deduped @@ -1031,6 +1165,7 @@ export function evaluateRuntimeAuthoringGate(args: { const rulesRun = [ ...result.rulesRun, ...(args.type === 'flow' ? [PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), + ...(args.type === 'view' ? [PUBLIC_FORM_INTAKE_UNAVAILABLE] : []), ...(pageSourceFindings !== null ? [HTML_PAGE_SOURCE_COMPILE, PAGE_REQUIRES_DISAGREES_WITH_SOURCE] : []), ]; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 239ac2d6d39..c51d70cf658 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -74,9 +74,14 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, - type AnonymousFormIntakeCandidate, - // [#21476] The wall column, and the ADR-0106 fingerprint, for the intake-availability predicate. - resolveRecordWallOrganizationField, + // [#21476] Whether such a form can take intake on this posture, and why not + // — the one predicate the runtime authoring gate's advisory reads too. + anonymousFormIntakePosture, + anonymousFormIntakeUnavailability, + anonymousFormIntakeUnavailableMessage, + anonymousFormObjectName, + anonymousFormSharingPath, + // [#21476] The ADR-0106 fingerprint the admin read folds the reason into. objectFieldVisibilityFingerprint, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; @@ -165,12 +170,7 @@ import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; // single-item read path rebuilds its body through, from the spec's own storage // predicates — so the signal the grid reads cannot drift from what the runtime // doors (#6994/#7095) refuse. -import { - PUBLIC_FORM_SERVER_MANAGED_FIELDS, - normalizeTenancyPosture, - postureEnforcesWall, - type TenancyPosture, -} from '@objectstack/spec/security'; +import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { PLURAL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -1759,57 +1759,11 @@ type MetaReadVerdict = | { kind: 'serve'; document: any } | { kind: 'refuse'; send: (res: any) => void }; -/** [#21476] Why an open public form cannot take an anonymous submission on this deployment. */ -interface AnonymousFormIntakeUnavailable { - /** The object the form submits into, the walled posture in force, and the column it is walled by. */ - object: string; - posture: TenancyPosture; - tenantField: string; -} - -/** - * [#21476] THE intake-availability predicate: `null` when an open public form - * can take an anonymous submission here, otherwise why it cannot. - * - * An anonymous submission carries no organization, and on a walled posture the - * engine refuses an insert without one into an object walled by an organization - * column (`resolveSystemInsertOrganization`, `@objectstack/objectql`). Such a - * form used to be served and then answer `500` on every submit; now both doors - * answer it as a withdrawn form and the admin read says why. Its two facts: - * - * - `posture`: the tenancy service's IN-FORCE posture, the value SecurityPlugin - * hands the engine (`setTenancyPostureProvider`), never re-read from env. A - * degraded walled request is `single` there, and the engine then derives the - * install's organization. `undefined` (no tenancy service) names no wall. - * - the wall column: `resolveRecordWallOrganizationField` - * (`@objectstack/metadata-core`), over the served object schema, which - * carries the injected `organization_id`. `readObjectSchema` runs only once - * a wall is in force. - * - * ⚠️ It reads declarations. The engine also passes a federated (`external`) - * object, a platform object its inventory has not admitted, and a row a - * `beforeInsert` hook stamped; a form bound to one of those with a wall column - * is withheld here although the engine would accept it (fail closed). - */ -async function anonymousFormIntakeUnavailability( - object: string, - posture: TenancyPosture | undefined, - readObjectSchema: () => Promise, -): Promise { - if (posture === undefined || !postureEnforcesWall(posture)) return null; - const objectSchema = await readObjectSchema(); - const fields = (objectSchema as { fields?: unknown } | null | undefined)?.fields; - const tenantField = resolveRecordWallOrganizationField( - objectSchema, - (field) => !!fields && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, field), - ); - return tenantField === null ? null : { object, posture, tenantField }; -} - -/** [#21476] The posture in force, as the tenancy service an anonymous form request reads reports it. */ -function anonymousFormTenancyPosture(tenancy: unknown): TenancyPosture | undefined { - return normalizeTenancyPosture((tenancy as { posture?: unknown } | undefined)?.posture); -} +// [#21476] The intake-availability predicate, its posture reader, the object a +// form submits into, where its `sharing` sits and the reason it states all live +// in `@objectstack/metadata-core` (`anonymous-form-intake.ts`): both doors, the +// admin read below and the runtime authoring gate's save/publish advisory read +// them from there, so none of the three can disagree with another. /** [#21331] The organization an anonymous form request reads the form in (`defaultOrgId()`). */ async function anonymousFormOrganization(tenancy: any): Promise { @@ -1818,33 +1772,6 @@ async function anonymousFormOrganization(tenancy: any): Promise, candidate: AnonymousFormIntakeCandidate): string { - if (candidate.form === view.form) return 'form.sharing'; - if (candidate.key !== undefined && view.formViews?.[candidate.key] === candidate.form) { - return `formViews.${candidate.key}.sharing`; - } - return 'config.sharing'; -} - -/** [#21476] The reason the admin read states, located at the form's `sharing`. */ -function anonymousFormIntakeUnavailableMessage(slug: string, u: AnonymousFormIntakeUnavailable): string { - return ( - `Public form '/forms/${slug}' is not offered to anonymous visitors on this deployment, so both ` - + `anonymous form doors answer it as not found. It submits into '${u.object}', which is walled by ` - + `'${u.tenantField}', and this deployment runs the '${u.posture}' tenancy posture: an anonymous ` - + `submission carries no organization, and an insert without one into a walled object is refused. ` - + `If the rows of '${u.object}' belong to no organization, declare that on the object ` - + `(tenancy: { enabled: false }) and the form is offered again. Otherwise collect this data through ` - + `a signed-in surface.` - ); -} - /** * [#21476] Put the admin read's intake reasons in `_diagnostics.warnings`, where * a derived view warning already goes (`stampRenameWarning`). A declared read @@ -10721,7 +10648,7 @@ export class RestServer { const candidates = anonymousFormIntakeCandidates(view); if (candidates.length === 0) return []; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); - const posture = anonymousFormTenancyPosture(tenancy); + const posture = anonymousFormIntakePosture(tenancy); let objects: Promise | undefined; const readObjects = (): Promise => (objects ??= anonymousFormOrganization(tenancy) .then((organizationId) => this.readFormObjectDefinitions(p, environmentId, organizationId))); @@ -10855,7 +10782,7 @@ export class RestServer { // doors, so an anonymous caller learns nothing about the tenancy. const unavailable = await anonymousFormIntakeUnavailability( match.object, - anonymousFormTenancyPosture(tenancy), + anonymousFormIntakePosture(tenancy), async () => (await this.readFormObjectDefinitions(p, environmentId, organizationId)) .find((o: any) => o?.name === match.object), );