diff --git a/.changeset/21468-walled-public-form-withdrawal.md b/.changeset/21468-walled-public-form-withdrawal.md new file mode 100644 index 00000000000..6c75c77fe62 --- /dev/null +++ b/.changeset/21468-walled-public-form-withdrawal.md @@ -0,0 +1,5 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring, with `403 NOT_OVERRIDABLE`, when the save is organization-scoped and the anonymous form doors cannot honour it. The message names the remedy: save the change env-wide, which every anonymous door honours. Drafts and draft promotion are refused alike. Other organization-scoped edits, env-wide saves and single-posture deployments are unchanged. diff --git a/packages/metadata-protocol/src/anonymous-form-intake.ts b/packages/metadata-protocol/src/anonymous-form-intake.ts new file mode 100644 index 00000000000..5a912cd4cff --- /dev/null +++ b/packages/metadata-protocol/src/anonymous-form-intake.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which public-form slugs a `view` body opens to anonymous intake. + * + * The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`, + * `registerFormEndpoints` in `@objectstack/rest`) serve a form when one of a + * view's form candidates carries `sharing.allowAnonymous === true` and a + * `sharing.publicLink` that names the slug. The candidates are the same three + * shapes those doors scan: the nested `form`, every `formViews` entry, and the + * flattened `config` of a `viewKind: 'form'` item. + * + * Returns the sorted, de-duplicated slug set, normalised the way the doors + * compare it (`/forms/x`, `forms/x` and `x` are one slug). Two bodies with the + * same set open exactly the same anonymous doors. + */ +export function anonymousFormIntakeSlugs(view: unknown): string[] { + if (!view || typeof view !== 'object') return []; + const v = view as Record; + const sharings: unknown[] = []; + if (v.form && typeof v.form === 'object') sharings.push(v.form.sharing); + if (v.formViews && typeof v.formViews === 'object') { + for (const fv of Object.values(v.formViews)) { + if (fv && typeof fv === 'object') sharings.push((fv as any).sharing); + } + } + if (v.viewKind === 'form' && v.config && typeof v.config === 'object') sharings.push(v.config.sharing); + const slugs = new Set(); + for (const s of sharings) { + if (!s || typeof s !== 'object') continue; + const sharing = s as Record; + if (sharing.allowAnonymous !== true) continue; + if (typeof sharing.publicLink !== 'string' || !sharing.publicLink) continue; + slugs.add(sharing.publicLink.replace(/^\/+/, '').replace(/^forms\//, '')); + } + return [...slugs].sort(); +} diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 4764a001df9..c0029686071 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -561,3 +561,114 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', expect(affected).toHaveLength(17); }); }); + +/** + * An org-scoped change to which public forms accept anonymous intake is + * refused when the anonymous form doors would never read that organization: + * they resolve the form in `tenancy.defaultOrgId()`'s organization, which a + * walled posture (degraded or not) answers `null`. Same stub engine as above; + * the `tenancy` service is the only addition. + */ +describe('org-scoped anonymous form intake changes the anonymous doors cannot see', () => { + const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: '/forms/walled-intake' }); + const FORM_VIEW = (allowAnonymous: boolean, label = 'Intake') => ({ + name: 'task.intake_form', + label, + object: 'task', + viewKind: 'form', + config: { sharing: sharing(allowAnonymous) }, + }); + + /** `defaultOrgId` answers what the anonymous doors resolve. */ + function makeTenancyProtocol(defaultOrgId: string | null) { + const { engine, rows } = makeStubEngine(); + const services = new Map([['tenancy', { defaultOrgId: async () => defaultOrgId }]]); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; + return { protocol, rows }; + } + + async function publishEnvWide(protocol: any) { + const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) }); + expect(res.success).toBe(true); + } + + it('walled (no organization for an anonymous request): the org-scoped withdrawal is refused and nothing is saved', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + const refusal = protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + }); + await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + await expect(refusal).rejects.toThrow(/Save it env-wide instead/); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('walled: an org-scoped draft of the withdrawal is refused too', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', mode: 'draft', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('walled: an org-scoped publish of a form the env-wide definition keeps private is refused', async () => { + const { protocol } = makeTenancyProtocol(null); + const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); + expect(res.success).toBe(true); + + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('walled: a legacy org-scoped draft of the withdrawal cannot be promoted', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + await seedLegacyOrgDraft(protocol, { + type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), organizationId: 'org_a', + }); + + await expect( + protocol.publishMetaItem({ type: 'view', name: 'task.intake_form', organizationId: 'org_a' }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows).filter((r) => r.org === 'org_a' && r.state === 'active')).toEqual([]); + }); + + it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (tenant)'), organizationId: 'org_a', + }); + expect(res.success).toBe(true); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ + { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, + ]); + }); + + it('control (walled): the env-wide withdrawal is accepted', async () => { + const { protocol } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); + expect(res.success).toBe(true); + expect(res.message).toContain('env-wide'); + }); + + it('control (single): the doors resolve this organization, so the org-scoped withdrawal is accepted', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + }); + expect(res.success).toBe(true); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ + { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, + ]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c41476c3401..efa95ff7517 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -38,6 +38,7 @@ import { // [#7560] ADR-0070's read-only-package rule, shared with the `/packages` // lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`. import { isWritablePackage as isWritablePackageShared } from './package-writability.js'; +import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js'; import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. @@ -14946,6 +14947,66 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * An organization-scoped `view` write that changes which public forms + * accept anonymous intake, on a deployment whose anonymous form doors do + * not read that organization. Returns the refusal, or `null` when the + * write is fine. + * + * An anonymous form request carries no session and so no organization. + * The doors resolve the form in `tenancy.defaultOrgId()`'s organization + * (`registerFormEndpoints` in `@objectstack/rest`). Where that is not the + * write's organization (every walled posture, degraded or not, answers + * `null`), the doors read the env-wide definition, so the write is refused + * and the author is pointed at the env-wide save, which every door + * honours. A composition with no tenancy service has no posture to judge + * (and no session to carry an organization over HTTP), so it is left as is. + * + * Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}): + * an organization-scoped edit that leaves it as the env-wide definition has + * it is unaffected. Same code and status as {@link orgScopedWriteRefusal}: + * this item's anonymous intake has no per-org channel on this deployment. + */ + private async anonymousFormIntakeOrgScopeRefusal(args: { + type: string; + name: string; + organizationId: string | null | undefined; + body: unknown; + }): Promise { + if (!args.organizationId) return null; + const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; + if (singular !== 'view') return null; + const tenancy = this.getServicesRegistry?.().get('tenancy') as + | { defaultOrgId?: () => Promise } + | undefined; + if (typeof tenancy?.defaultOrgId !== 'function') return null; + const doorOrganization = await tenancy.defaultOrgId(); + if (doorOrganization === args.organizationId) return null; + const proposed = anonymousFormIntakeSlugs(args.body); + const served = anonymousFormIntakeSlugs( + ((await this.getMetaItem({ type: singular, name: args.name })) as any)?.item, + ); + if (proposed.length === served.length && proposed.every((s, i) => s === served[i])) return null; + const list = (slugs: string[]) => (slugs.length ? slugs.map((s) => `'${s}'`).join(', ') : 'none'); + const err: any = new Error( + `Metadata item 'view/${args.name}' cannot change which public forms accept anonymous intake ` + + `in organization '${args.organizationId}' (env-wide: ${list(served)}; this write: ${list(proposed)}). ` + + `An anonymous form request carries no organization, and this deployment resolves ` + + (doorOrganization + ? `it in organization '${doorOrganization}'` + : `none for it (a walled tenancy posture never guesses one)`) + + `, so the anonymous form doors serve the env-wide definition and would never see this change. ` + + `Save it env-wide instead (retry with no active organization): that withdraws or publishes the form ` + + `on every anonymous door. An organization-scoped edit that leaves the form's sharing as the env-wide ` + + `definition has it is still accepted. See docs/adr/0005-metadata-customization-overlay.md.` + ); + err.code = 'NOT_OVERRIDABLE'; + err.status = 403; + err.organizationId = args.organizationId; + err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; + return err; + } + /** * Does an artifact (npm-package-loaded) item exist at `(type, name)`? * @@ -17722,6 +17783,18 @@ export class ObjectStackProtocolImplementation implements ); if (orgRefusal) throw orgRefusal; } + // An org-scoped change to a form's anonymous intake that the anonymous + // form doors cannot see. Drafts too, so no draft is minted that its + // own promotion would refuse. See {@link anonymousFormIntakeOrgScopeRefusal}. + { + const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ + type: request.type, + name: request.name, + organizationId: request.organizationId, + body: request.item, + }); + if (intakeRefusal) throw intakeRefusal; + } if (this.environmentId !== undefined) { // [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a @@ -19675,6 +19748,17 @@ export class ObjectStackProtocolImplementation implements const nameRefusal = savedItemNameRefusal(singularType, draftForGate.body, request.name, 'publish'); if (nameRefusal) throw nameRefusal; } + // The promotion half of {@link anonymousFormIntakeOrgScopeRefusal}: a + // draft saved before that refusal existed must not reach `active`. + if (draftForGate) { + const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ + type: singularType, + name: request.name, + organizationId: orgId, + body: draftForGate.body, + }); + if (intakeRefusal) throw intakeRefusal; + } // [#9176] The gate's return is its advisory half (#4717): captured and // handed out so `publishMetaItem` can attach it to the 2xx this // promotion is about to earn, exactly as `saveMetaItem` attaches its diff --git a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts new file mode 100644 index 00000000000..f68e0e4d264 --- /dev/null +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Withdrawing a public form from anonymous intake on a WALLED tenancy posture, +// on a real boot. The single-posture half lives in +// `showcase-public-form-withdrawal.dogfood.test.ts`. +// +// The form here is a fixture bound to an object declared `tenancy: { enabled: +// false }`. The showcase's own contact form targets a tenant-scoped object, +// and on a walled posture the engine refuses an org-less insert into one, so +// it could not show the published side accepting intake. +// +// On a walled posture the anonymous form doors read the env-wide form +// definition, so an organization-scoped change to a form's anonymous intake is +// refused at the save door, naming the env-wide save as the remedy. Pinned: +// +// - the organization-scoped withdrawal answers `403 NOT_OVERRIDABLE` and +// nothing is saved (the organization still reads the published form, and +// both doors still serve it); +// - an organization-scoped edit that leaves the sharing alone still saves; +// - the env-wide withdrawal is accepted and both anonymous doors answer +// `404 FORM_NOT_FOUND`, with no row landing; republishing env-wide restores +// both doors. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack, defineView } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +const LEAD = 'walled_lead'; +const SLUG = 'walled-lead-intake'; +const VIEW = `/meta/view/${LEAD}.intake`; + +const WalledLead = ObjectSchema.create({ + name: LEAD, + label: 'Walled Lead', + pluralLabel: 'Walled Leads', + sharingModel: 'private', + tenancy: { enabled: false }, + fields: { + name: Field.text({ label: 'Name', required: true, maxLength: 120 }), + }, +}); + +const data = { provider: 'object' as const, object: LEAD }; +const WalledLeadViews = defineView({ + list: { label: 'Leads', type: 'grid', data, columns: [{ field: 'name' }] }, + formViews: { + intake: { + type: 'simple', + data, + sections: [{ name: 'intake', label: 'Intake', columns: 1, fields: [{ field: 'name', required: true }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + }, + }, +}); + +const walledFormStack = defineStack({ + manifest: { + id: 'com.dogfood.walled-public-form', + namespace: 'walled', + version: '0.0.0', + type: 'app', + name: 'Walled Public Form Fixture', + description: 'One tenancy-disabled object behind an anonymous form, booted on a walled posture.', + }, + objects: [WalledLead], + views: [WalledLeadViews], +}); +const SYS = { isSystem: true } as const; + +interface Probe { + get: number; + getCode: unknown; + submit: number; + submitCode: unknown; + landed: unknown[]; +} + +describe('walled posture: withdrawing a public form from anonymous intake', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let published: Record; + let orgId: string; + let probeSeq = 0; + + /** Both anonymous doors, plus the rows a submit with a unique marker left. */ + const probe = async (): Promise => { + const marker = `walled_withdrawal_probe_${++probeSeq}`; + const get = await stack.api(`/forms/${SLUG}`); + const getBody = (await get.json()) as { code?: unknown }; + const submit = await stack.api(`/forms/${SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: marker }), + }); + const submitBody = (await submit.json()) as { code?: unknown }; + const landed = await ql.find(LEAD, { where: { name: marker }, context: SYS }); + return { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed }; + }; + + const setActive = async (organizationId: string | null): Promise => { + const res = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + /** PUT the form at the admin's current session scope. */ + const put = async (body: Record) => { + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const json = (await res.json()) as Record; + return { status: res.status, json }; + }; + + const withAnonymous = (allowAnonymous: boolean): Record => { + const body = structuredClone(published); + body.config.sharing.allowAnonymous = allowAnonymous; + return body; + }; + + /** The effective form as the admin's current scope reads it. */ + const read = async (): Promise> => { + const res = await stack.apiAs(admin, 'GET', VIEW); + expect(res.status).toBe(200); + const json = (await res.json()) as { item?: Record }; + return (json.item ?? json) as Record; + }; + + beforeAll(async () => { + stack = await bootStack(walledFormStack as Parameters[0], { + multiTenant: 'posture-only', + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const item = await read(); + // The editor's PUT body: the effective item without its read decorations. + published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + expect(published.config?.sharing?.allowAnonymous).toBe(true); + + const created = await stack.apiAs(admin, 'POST', '/auth/organization/create', { + name: 'Walled Tenant', slug: 'walled-tenant-form', + }); + expect(created.status, await created.clone().text()).toBe(200); + orgId = ((await created.json()) as { id: string }).id; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: a walled posture that resolves no organization for an anonymous request', async () => { + const tenancy = stack.tenancy(); + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.requested).toBe(true); + expect(await tenancy.defaultOrgId()).toBeNull(); + expect(orgId).toBeTruthy(); + }); + + it('PRECONDITION: the published form accepts anonymous intake', async () => { + const p = await probe(); + expect([p.get, p.submit]).toEqual([200, 201]); + expect(p.landed).toHaveLength(1); + }); + + it('withdrawn in an organization: refused 403 NOT_OVERRIDABLE naming the env-wide save, and nothing is saved', async () => { + await setActive(orgId); + const res = await put(withAnonymous(false)); + expect(res.status, JSON.stringify(res.json)).toBe(403); + const error = (res.json.error ?? res.json) as Record; + expect(error.code ?? res.json.code).toBe('NOT_OVERRIDABLE'); + expect(JSON.stringify(res.json)).toMatch(/Save it env-wide instead/); + + expect((await read()).config?.sharing?.allowAnonymous, 'the organization still reads the published form').toBe(true); + const p = await probe(); + expect([p.get, p.submit]).toEqual([200, 201]); + expect(p.landed).toHaveLength(1); + }); + + it('an organization-scoped edit that leaves the sharing alone still saves (control)', async () => { + await setActive(orgId); + const body = structuredClone(published); + body.label = `${String(published.label ?? 'Intake')} (tenant)`; + const res = await put(body); + expect(res.status, JSON.stringify(res.json)).toBe(200); + expect(String(res.json.message ?? '')).toContain(`org=${orgId}`); + }); + + it('withdrawn env-wide: both doors answer 404 FORM_NOT_FOUND and nothing lands; republishing restores them', async () => { + await setActive(null); + const off = await put(withAnonymous(false)); + expect(off.status, JSON.stringify(off.json)).toBe(200); + expect(String(off.json.message ?? '')).toMatch(/env-wide/); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); + + const on = await put(withAnonymous(true)); + expect(on.status, JSON.stringify(on.json)).toBe(200); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); + }); +});