From 25a834d44bb09fe36b87a5380ba58dd8eda65591 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:24:23 +0000 Subject: [PATCH 01/19] fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/anonymous-form-intake.ts | 97 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 68 ++++++++++++- packages/rest/src/rest-server.ts | 35 ++++++- 3 files changed, 194 insertions(+), 6 deletions(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index c347eb75d5d..d802ef3e236 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -22,6 +22,10 @@ * * Clearing either switch withdraws the form from every anonymous door. * + * A withdrawal is a kill switch: any metadata layer the doors read that + * withdraws a form 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. @@ -240,3 +244,96 @@ export function anonymousFormIntakeUnavailableMessage(slug: string, u: Anonymous + anonymousFormIntakeUnavailableRemedy(u) ); } + +/** + * Where a form sits in a `view` body, independent of its content: the nested + * `form`, one `formViews` entry, or the flattened `config`. Two bodies of the + * same view (one per metadata layer) carry "the same form" at the same slot. + */ +function anonymousFormSlot(view: Record, candidate: AnonymousFormIntakeCandidate): string { + if (candidate.form === view.form) return 'form'; + if (candidate.key !== undefined && view.formViews?.[candidate.key] === candidate.form) { + return `formViews:${candidate.key}`; + } + return 'config'; +} + +/** The `sharing` a `view` body carries at a slot (see {@link anonymousFormSlot}), if any. */ +function anonymousFormSharingAtSlot(view: Record, slot: string): unknown { + if (slot === 'form') return view.form?.sharing; + if (slot.startsWith('formViews:')) return view.formViews?.[slot.slice('formViews:'.length)]?.sharing; + return view.viewKind === 'form' ? view.config?.sharing : undefined; +} + +/** Every form `sharing` a `view` body carries, in scan order (open or not). */ +function anonymousFormSharings(view: unknown): unknown[] { + if (!view || typeof view !== 'object') return []; + const v = view as Record; + const out: unknown[] = []; + if (v.form && typeof v.form === 'object') out.push(v.form.sharing); + if (v.formViews && typeof v.formViews === 'object') { + for (const fv of Object.values(v.formViews)) { + if (fv && typeof fv === 'object') out.push((fv as Record).sharing); + } + } + if (v.viewKind === 'form' && v.config && typeof v.config === 'object') out.push(v.config.sharing); + return out; +} + +/** + * The slugs a `view` body WITHDRAWS: a form `sharing` names the slug in its + * `publicLink` but does not open it (`enabled` or `allowAnonymous` is not + * `true`). Sorted and de-duplicated. + */ +export function anonymousFormWithdrawnSlugs(view: unknown): string[] { + const withdrawn = new Set(); + for (const sharing of anonymousFormSharings(view)) { + if (!sharing || typeof sharing !== 'object') continue; + const publicLink = (sharing as Record).publicLink; + if (typeof publicLink !== 'string' || !publicLink) continue; + if (anonymousFormIntakeSlug(sharing) !== null) continue; + withdrawn.add(publicFormSlug(publicLink)); + } + return [...withdrawn].sort(); +} + +/** + * Does one metadata layer — the full `view` list one read answers — withdraw + * an open form candidate? A WITHDRAWAL IS A KILL SWITCH: layering may only + * narrow anonymous intake, never re-open it, so the anonymous form doors serve + * a candidate only when no layer they read withdraws it (and the + * organization-scoped write door refuses a write that would re-open one). + * + * The layer withdraws it when either holds: + * + * - some view in the layer names the candidate's slug in a `sharing` it does + * not open ({@link anonymousFormWithdrawnSlugs}) — the slug is withdrawn, + * whichever view withdrew it; + * - the layer's own body of the same view (by `name`) switches the form at the + * same slot off: `sharing.enabled === false` or `sharing.allowAnonymous === + * false`, whatever its `publicLink` says. + * + * A layer with no body of the view and no word on the slug withdraws nothing, + * so a form published only in an organization stays open there. + */ +export function anonymousFormIntakeWithdrawnIn( + layer: ReadonlyArray, + view: unknown, + candidate: AnonymousFormIntakeCandidate, +): boolean { + if (!view || typeof view !== 'object') return false; + const v = view as Record; + const slot = anonymousFormSlot(v, candidate); + const name = typeof v.name === 'string' && v.name ? v.name : undefined; + for (const other of layer) { + if (!other || typeof other !== 'object') continue; + if (anonymousFormWithdrawnSlugs(other).includes(candidate.slug)) return true; + const o = other as Record; + if (name === undefined || o.name !== name) continue; + const sharing = anonymousFormSharingAtSlot(o, slot); + if (!sharing || typeof sharing !== 'object') continue; + const s = sharing as Record; + if (s.enabled === false || s.allowAnonymous === false) return true; + } + return false; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c3958342651..4382f23fa7d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -102,6 +102,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, + // A withdrawal is a kill switch: the doors' layer predicate, which the + // org-scoped write door asks before accepting a re-opening write. + anonymousFormIntakeCandidates, + anonymousFormIntakeWithdrawnIn, // [#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()`). @@ -15683,7 +15687,9 @@ export class ObjectStackProtocolImplementation implements | undefined; if (typeof tenancy?.defaultOrgId !== 'function') return null; const doorOrganization = await tenancy.defaultOrgId(); - if (doorOrganization === args.organizationId) return null; + if (doorOrganization === args.organizationId) { + return this.anonymousFormIntakeReopenRefusal({ ...args, type: singular, organizationId: args.organizationId }); + } const proposed = anonymousFormIntakeSlugs(args.body); const served = anonymousFormIntakeSlugs( ((await this.getMetaItem({ type: singular, name: args.name })) as any)?.item, @@ -15709,6 +15715,66 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * An organization-scoped `view` write, in the organization the anonymous + * form doors read, that would re-open a public form the env-wide layer + * withdrew. Returns the refusal, or `null` when the write is fine. + * + * A withdrawal is a kill switch: any layer that withdraws a public form's + * intake (or its anonymous access) closes it, and layering may only narrow + * intake, never re-open it. The doors enforce that at read time + * (`registerFormEndpoints` in `@objectstack/rest` reads the env-wide layer + * beneath the organization's and lets either withdraw), so such a write + * would be accepted and then never honoured. It is refused instead, and the + * author is pointed at the env-wide definition, which is the switch. + * + * Judged by the doors' own predicate ({@link anonymousFormIntakeWithdrawnIn}) + * over the env-wide `view` list, and only for a slug this write opens that + * the organization's current definition does not: an edit that leaves the + * organization's intake as it is (or withdraws it) is never refused here. + */ + private async anonymousFormIntakeReopenRefusal(args: { + type: string; + name: string; + organizationId: string; + body: unknown; + }): Promise { + if (!args.body || typeof args.body !== 'object') return null; + const body = { ...(args.body as Record), name: args.name }; + const proposed = anonymousFormIntakeCandidates(body); + if (proposed.length === 0) return null; + const current = new Set(anonymousFormIntakeSlugs( + ((await this.getMetaItem({ type: args.type, name: args.name, organizationId: args.organizationId })) as any) + ?.item, + )); + const opening = proposed.filter((c) => !current.has(c.slug)); + if (opening.length === 0) return null; + const envWide: any = await this.getMetaItems({ type: args.type }); + const layer: unknown[] = Array.isArray(envWide?.items) ? envWide.items : []; + const reopened = [...new Set( + opening.filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)).map((c) => c.slug), + )].sort(); + if (reopened.length === 0) return null; + const list = reopened.map((s) => `'/forms/${s}'`).join(', '); + const err: any = new Error( + `Metadata item 'view/${args.name}' cannot re-open public form ${list} for anonymous intake ` + + `in organization '${args.organizationId}': the env-wide definition withdraws it. A withdrawal is ` + + `a kill switch, so an organization overlay may narrow a public form's intake but never re-open it, ` + + `and the anonymous form doors would keep answering it as not found. To publish it again, save the ` + + `form env-wide (retry with no active organization) with sharing enabled and anonymous access ` + + `allowed. An organization-scoped edit that keeps this form withdrawn is still accepted. ` + + `See docs/adr/0005-metadata-customization-overlay.md.` + ); + err.code = 'NOT_OVERRIDABLE'; + err.status = 403; + // The sentence an end user is shown (the producer-declared channel). + err.userMessage = `This public form was withdrawn for the whole environment, so it cannot be re-opened ` + + `for one organization. Publish it again from the environment-wide form definition.`; + 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)`? * diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e49a3d11023..3f30cb52296 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -77,6 +77,7 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, + anonymousFormIntakeWithdrawnIn, // [#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, @@ -10714,11 +10715,22 @@ export class RestServer { // shared with the write-time judgement in `@objectstack/metadata-protocol` // (`anonymousFormIntakeCandidates`): `sharing.enabled === true`, // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. - const findPublicFormView = (views: any[], slug: string): { view: any; form: any; object: string } | null => { + // + // A withdrawal is a kill switch: layering may only narrow anonymous + // intake, never re-open it. A candidate is served only when no layer + // the doors read withdraws it (`anonymousFormIntakeWithdrawnIn`): the + // read the form is found in, and, when that read is an organization's, + // the env-wide read beneath it. + const findPublicFormView = ( + views: any[], + slug: string, + layers: ReadonlyArray>, + ): { view: any; form: any; object: string } | null => { for (const view of views ?? []) { if (!view || typeof view !== 'object') continue; for (const c of anonymousFormIntakeCandidates(view)) { if (c.slug !== slug) continue; + if (layers.some((layer) => anonymousFormIntakeWithdrawnIn(layer, view, c))) continue; const objectName = anonymousFormObjectName(view, c.form); if (!objectName) continue; return { view, form: c.form, object: objectName }; @@ -10772,13 +10784,26 @@ export class RestServer { ...(environmentId ? { environmentId } : {}), ...(organizationId ? { organizationId } : {}), }; - const result: any = await p.getMetaItems(viewsRequest); - const items: any[] = Array.isArray(result?.items) + const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result - : []; - const match = findPublicFormView(items, slug); + : []); + const items = listOf(await p.getMetaItems(viewsRequest)); + // The organization read prefers the organization's overlay of a + // view over the env-wide one, so on its own it cannot see an + // env-wide withdrawal that overlay disagrees with. Read the + // env-wide layer too, and let either layer's withdrawal close the + // form: an organization overlay can narrow intake, never re-open it. + const layers: any[][] = [items]; + if (organizationId) { + const envWideRequest: TransportScopedMetaRequest = { + type: 'view', + ...(environmentId ? { environmentId } : {}), + }; + layers.push(listOf(await p.getMetaItems(envWideRequest))); + } + const match = findPublicFormView(items, slug, layers); if (!match) return null; // [#21476] A form that cannot take intake on this posture is not // offered: `null` here IS the withdrawn form's answer on both From f226f1bfa899f2a10b4c23a396d78a16dff11adb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:39:05 +0000 Subject: [PATCH 02/19] test: pin the public-form withdrawal kill switch across metadata layers Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/anonymous-form-intake.test.ts | 55 +++++++++++++ .../protocol.org-scoped-write-refused.test.ts | 56 +++++++++++++ .../public-form-intake-availability.test.ts | 4 +- .../rest/src/public-form-withdrawal.test.ts | 80 +++++++++++++++++-- 4 files changed, 186 insertions(+), 9 deletions(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index ed6e3c81dc4..cd518a058f5 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -10,6 +10,8 @@ import { anonymousFormIntakeUnavailability, anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, + anonymousFormIntakeWithdrawnIn, + anonymousFormWithdrawnSlugs, anonymousFormObjectName, anonymousFormSharingPath, publicFormSlug, @@ -184,3 +186,56 @@ describe('where the reason is located, and the reason itself', () => { expect(message.endsWith(` ${anonymousFormIntakeUnavailableRemedy(u)}`)).toBe(true); }); }); + +describe('anonymousFormIntakeWithdrawnIn — a withdrawal at any layer is a kill switch', () => { + const view = (sharing: unknown, name = 'contact') => ({ + name, object: 'inquiry', viewKind: 'form', config: { sharing }, + }); + const openView = view(OPEN); + const [candidate] = anonymousFormIntakeCandidates(openView); + + it('anonymousFormWithdrawnSlugs: a sharing naming a slug it does not open withdraws that slug', () => { + expect(anonymousFormWithdrawnSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual(['contact-us']); + expect(anonymousFormWithdrawnSlugs(view({ ...OPEN, enabled: false }))).toEqual(['contact-us']); + expect(anonymousFormWithdrawnSlugs(view({ allowAnonymous: true, publicLink: 'contact-us' }))).toEqual(['contact-us']); + expect(anonymousFormWithdrawnSlugs(openView)).toEqual([]); + expect(anonymousFormWithdrawnSlugs(view({ enabled: false }))).toEqual([]); + expect(anonymousFormWithdrawnSlugs(view(undefined))).toEqual([]); + }); + + it('the same view switched off at the same slot withdraws, whatever its public link says', () => { + expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, allowAnonymous: false })], openView, candidate)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false })], openView, candidate)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([view({ allowAnonymous: false, enabled: true })], openView, candidate)).toBe(true); + }); + + it('another view withdrawing the slug withdraws it', () => { + const layer = [view({ ...OPEN, enabled: false }, 'legacy_contact')]; + expect(anonymousFormIntakeWithdrawnIn(layer, openView, candidate)).toBe(true); + }); + + it('a layer that opens it, or has no word on it, withdraws nothing', () => { + expect(anonymousFormIntakeWithdrawnIn([openView], openView, candidate)).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([], openView, candidate)).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view(undefined)], openView, candidate)).toBe(false); + // Another view switched off at its own slot, naming no slug: not this form. + expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false }, 'other')], openView, candidate)).toBe(false); + // A different slug withdrawn elsewhere: not this form. + expect(anonymousFormIntakeWithdrawnIn( + [view({ ...OPEN, enabled: false, publicLink: '/forms/other' }, 'other')], openView, candidate, + )).toBe(false); + }); + + it('slots are matched per shape: a formViews entry is judged against the same key only', () => { + const nested = (a: unknown, b: unknown) => ({ + name: 'multi', object: 'inquiry', + formViews: { a: { sharing: a }, b: { sharing: b } }, + }); + const opened = nested({ ...OPEN, publicLink: '/forms/a' }, { ...OPEN, publicLink: '/forms/b' }); + const [ca, cb] = anonymousFormIntakeCandidates(opened); + const layer = [nested({ enabled: false }, { ...OPEN, publicLink: '/forms/b' })]; + expect(anonymousFormIntakeWithdrawnIn(layer, opened, ca)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn(layer, opened, cb)).toBe(false); + }); +}); + 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 0c22dd43ca7..cd5bd9d8416 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 @@ -199,6 +199,8 @@ function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { getItem: () => undefined, getObject: () => undefined, getPackage: () => undefined, + // The env-wide `view` list read (the public-form re-open refusal). + isPackageDisabled: () => false, // `isArtifactBacked` prefers this lookup — a hit means the name is // shipped by a code package (`_packageId` provenance). getArtifactItem: (type: string, name: string) => @@ -683,4 +685,58 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, ]); }); + + // A withdrawal is a kill switch: in the organization the doors DO read, an + // org-scoped write may narrow intake but never re-open a form the env-wide + // definition withdrew (the doors would keep answering it as not found). + it('single: an org-scoped re-open of a form the env-wide definition withdrew is refused and nothing is saved', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); + expect(res.success).toBe(true); + + const refusal = protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + }); + await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + await expect(refusal).rejects.toThrow(/cannot re-open public form '\/forms\/walled-intake'/); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('single: the re-open through `sharing.enabled` is refused when the env-wide definition switched it off', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + const off = FORM_VIEW(true); + off.config.sharing.enabled = false; + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: off })).success).toBe(true); + + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', mode: 'draft', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('control (single): an org-scoped edit that keeps the env-wide withdrawal still saves', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); + + const res = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false, '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 (single): an org-scoped republish over an env-wide published form still saves', async () => { + const { protocol } = makeTenancyProtocol('org_a'); + await publishEnvWide(protocol); + const off = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + }); + expect(off.success).toBe(true); + const on = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + }); + expect(on.success).toBe(true); + }); }); diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index 8ab4cb88b41..d55273f3996 100644 --- a/packages/rest/src/public-form-intake-availability.test.ts +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -171,7 +171,9 @@ describe('[#21476] a public form that cannot take intake on this posture is not it('CONTROL: single posture, walled object — accepted, and the object is not even read for the predicate', async () => { const s = build({ tenancy: 'single' }); expect((await s.post()).statusCode).toBe(201); - expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view']); + // The organization's view read and the env-wide one beneath it (a + // withdrawal at either layer closes the form); no object read. + expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view', 'view']); }); it('CONTROL: a degraded walled request reads the posture IN FORCE (single) — accepted', async () => { diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 13817605e69..1b67cfe6d31 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -16,6 +16,14 @@ // - withdrawn env-wide with no organization to resolve: both doors refuse; // - tenancy never registered: the env-wide read, unchanged; // - tenancy registered but unreachable: both doors refuse (fail closed). +// +// A withdrawal is a kill switch: any layer the doors read that withdraws the +// form closes it, and layering may only narrow intake, never re-open it. The +// doors therefore read the env-wide layer beneath the organization's too: +// - withdrawn env-wide, re-opened in the organization: both doors refuse; +// - withdrawn in the organization, open env-wide: both doors refuse; +// - open in both: both doors accept; +// - a form only the organization has (no env-wide body): it is served. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -82,14 +90,19 @@ interface Setup { tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; /** Replaces the form's whole `sharing` on every read (the `envWide`/`inOrg` switch is then ignored). */ sharing?: Record; + /** Replaces the env-wide read's whole view list (the organization read is unchanged). */ + envWideViews?: unknown[]; + /** Extra views every read answers alongside the form view. */ + extraViews?: unknown[]; } function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => { if (req.type === 'view') { + if (req.organizationId !== ORG && setup.envWideViews) return setup.envWideViews; const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; - return [formView(effective, setup.sharing)]; + return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -159,15 +172,15 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); + // Every read names the organization, except the env-wide view read the + // kill switch adds beneath the organization's view read. const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.organizationId]); expect(reads.length).toBeGreaterThan(0); - for (const [, organizationId] of reads) expect(organizationId).toBe(ORG); - }); - - it('an organization overlay that publishes a form the package withdrew is honoured too', async () => { - const s = build({ envWide: false, inOrg: true, tenancy: 'org' }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); + for (const [type, organizationId] of reads) { + if (type !== 'view') expect(organizationId).toBe(ORG); + } + // One resolution per door: the organization's view read, then the env-wide one. + expect(reads.filter(([type]) => type === 'view').map(([, o]) => o)).toEqual([ORG, undefined, ORG, undefined]); }); it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => { @@ -227,3 +240,54 @@ describe('either declared switch withdraws a public form from every anonymous do }); } }); + +describe('a public form withdrawal is a kill switch: layering only narrows intake', () => { + const expectClosed = async (s: ReturnType) => { + const get = await s.get(); + expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); + const post = await s.post(); + expect([post.statusCode, post.body.code]).toEqual([404, 'FORM_NOT_FOUND']); + expect(s.createData).not.toHaveBeenCalled(); + }; + + it('withdrawn env-wide (allowAnonymous false), re-opened in the organization: both doors refuse', async () => { + await expectClosed(build({ envWide: false, inOrg: true, tenancy: 'org' })); + }); + + it('withdrawn env-wide through `enabled: false`, re-opened in the organization: both doors refuse', async () => { + const withdrawn = formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }); + await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); + }); + + it('switched off env-wide with its public link cleared, re-opened in the organization: both doors refuse', async () => { + const withdrawn = formView(true, { enabled: true, allowAnonymous: false }); + await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); + }); + + it('the slug withdrawn env-wide by another view: an organization view opening it is refused', async () => { + const other = { ...formView(false), name: 'legacy_contact' }; + await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] })); + }); + + it('withdrawn in the organization, open env-wide: both doors refuse', async () => { + await expectClosed(build({ envWide: true, inOrg: false, tenancy: 'org' })); + }); + + it('open in both layers (control): both doors accept', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); + }); + + it('a form only the organization carries (no env-wide body, control): both doors accept', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + it('one read: another view withdrawing the same slug closes it with no organization to resolve', async () => { + const other = { ...formView(false), name: 'legacy_contact' }; + await expectClosed(build({ envWide: true, tenancy: 'no-org', extraViews: [other] })); + }); +}); From e7aa2eb1a007471e8f9626076944f2b2f4f5bc06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:39:41 +0000 Subject: [PATCH 03/19] test(dogfood): a public form withdrawal at any layer holds on a real showcase boot Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- ...lic-form-withdrawal-layers.dogfood.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts new file mode 100644 index 00000000000..23385e07218 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A public form withdrawal is a kill switch, on a real showcase boot: any +// metadata layer that withdraws a public form's intake closes it, and layering +// may only narrow intake, never re-open it. +// +// The showcase ships `showcase_inquiry.contact`, a FormView open to anonymous +// intake at `/forms/contact-us`. The administrator saves it the way the editor +// does (`PUT /meta/view/...` at their own session), env-wide (no active +// organization) or in their organization (`orgContext: true` gives the admin +// one, and it is the organization the anonymous doors read). Pinned: +// +// - an organization overlay that keeps the form open does not survive an +// env-wide withdrawal: both anonymous doors answer `404 FORM_NOT_FOUND` +// and nothing lands; +// - an organization-scoped save that would re-open it is refused +// (`403 NOT_OVERRIDABLE`) and the doors stay closed; +// - withdrawn in the organization while open env-wide: closed; +// - open at both layers (control): both doors accept. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +const VIEW = '/meta/view/showcase_inquiry.contact'; +const SYS = { isSystem: true } as const; + +describe('showcase: a public form withdrawal at any metadata layer holds', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let published: Record; + let organizationId: string; + let probeSeq = 0; + + /** Both anonymous doors, plus how many rows a submit with a unique marker left. */ + const probe = async () => { + const marker = `layer_probe_${++probeSeq}`; + const get = await stack.api('/forms/contact-us'); + const getBody = (await get.json()) as { code?: unknown }; + const submit = await stack.api('/forms/contact-us/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: marker, email: 'probe@example.com', message: 'probe' }), + }); + const submitBody = (await submit.json()) as { code?: unknown }; + const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); + return { doors: [get.status, getBody.code ?? null, submit.status, submitBody.code ?? null], landed: landed.length }; + }; + + const CLOSED = { doors: [404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND'], landed: 0 }; + const OPEN = { doors: [200, null, 201, null], landed: 1 }; + + /** Switch the admin's session scope: env-wide (`null`) or the organization. */ + const scope = async (org: string | null) => { + const res = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: org }); + expect(res.status).toBe(200); + }; + + /** Save the form at the current scope with `allowAnonymous` set; answers status and body. */ + const save = async (allowAnonymous: boolean) => { + const body = structuredClone(published); + body.config.sharing.allowAnonymous = allowAnonymous; + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + return { status: res.status, json: (await res.json()) as Record }; + }; + + const saved = async (allowAnonymous: boolean) => { + const r = await save(allowAnonymous); + expect(r.status, JSON.stringify(r.json)).toBe(200); + return String(r.json.message ?? ''); + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + orgContext: true, + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const res = await stack.apiAs(admin, 'GET', VIEW); + expect(res.status).toBe(200); + const json = (await res.json()) as { item?: Record }; + const item = (json.item ?? json) as Record; + published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + expect(published.config?.sharing).toMatchObject({ enabled: true, allowAnonymous: true }); + const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); + expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1); + organizationId = orgs[0].id; + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: an organization overlay that keeps the form open is served', async () => { + await scope(organizationId); + expect(await saved(true), 'the save is an organization overlay').toContain(`org=${organizationId}`); + expect(await probe()).toEqual(OPEN); + }); + + it('withdrawn env-wide beneath an open organization overlay: both doors refuse and nothing lands', async () => { + await scope(null); + expect(await saved(false)).toMatch(/env-wide/); + expect(await probe()).toEqual(CLOSED); + }); + + it('an organization-scoped save that would re-open it is refused, and the doors stay closed', async () => { + await scope(organizationId); + // The organization overlay is still open, so withdraw it there first; the + // re-open is then this write's own doing. + expect((await save(false)).status).toBe(200); + const reopen = await save(true); + expect(reopen.status, JSON.stringify(reopen.json)).toBe(403); + expect(reopen.json.code ?? reopen.json.error?.code).toBe('NOT_OVERRIDABLE'); + expect(await probe()).toEqual(CLOSED); + }); + + it('withdrawn in the organization while open env-wide: both doors refuse', async () => { + await scope(null); + expect(await saved(true)).toMatch(/env-wide/); + expect(await probe()).toEqual(CLOSED); + }); + + it('open at both layers (control): both doors accept and the row lands in the organization', async () => { + await scope(organizationId); + expect(await saved(true)).toContain(`org=${organizationId}`); + const marker = `layer_probe_${probeSeq + 1}`; + expect(await probe()).toEqual(OPEN); + const [row] = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); + expect(row.organization_id).toBe(organizationId); + }); +}); From afb150b5bcfb37b20eafed3306f5837ce36638df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:50:13 +0000 Subject: [PATCH 04/19] chore: changeset for the public-form withdrawal kill switch Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/public-form-withdrawal-kill-switch.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/public-form-withdrawal-kill-switch.md diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md new file mode 100644 index 00000000000..44241dfd03d --- /dev/null +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -0,0 +1,13 @@ +--- +'@objectstack/rest': patch +'@objectstack/metadata-protocol': patch +'@objectstack/metadata-core': patch +--- + +A public form's intake withdrawal at any metadata layer now holds: layering can only narrow anonymous intake, never re-open it + +Clause-②: no + +- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a public form only when no metadata layer they read withdraws it. A form withdrawn at one layer (`sharing.enabled: false` or `sharing.allowAnonymous: false`, or its public link named by a sharing that does not open it) answers `404 FORM_NOT_FOUND` on both doors and creates no record, whatever another layer says. A form open at every layer is served as before, and a form only an organization carries is still served there. +- **Organization-scoped saves.** A `view` save that would re-open a public form another layer withdrew is refused with `403 NOT_OVERRIDABLE`, and the message names the remedy: publish the form from its environment-wide definition. An organization-scoped edit that keeps the form withdrawn, or that leaves its intake as it is, is still accepted. +- **`@objectstack/metadata-core`** exports the shared judgement, `anonymousFormIntakeWithdrawnIn` and `anonymousFormWithdrawnSlugs`, which both the doors and the save path read. From 4a3d5489dc3da5deb167d59a134166430c12308d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:25:02 +0000 Subject: [PATCH 05/19] =?UTF-8?q?chore(changeset):=20metadata-core=20gains?= =?UTF-8?q?=20two=20public=20exports=20=E2=80=94=20minor,=20widening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/public-form-withdrawal-kill-switch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index 44241dfd03d..0f3fd5917c8 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -1,13 +1,13 @@ --- '@objectstack/rest': patch '@objectstack/metadata-protocol': patch -'@objectstack/metadata-core': patch +'@objectstack/metadata-core': minor --- A public form's intake withdrawal at any metadata layer now holds: layering can only narrow anonymous intake, never re-open it -Clause-②: no +Clause-②: yes (widening) - **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a public form only when no metadata layer they read withdraws it. A form withdrawn at one layer (`sharing.enabled: false` or `sharing.allowAnonymous: false`, or its public link named by a sharing that does not open it) answers `404 FORM_NOT_FOUND` on both doors and creates no record, whatever another layer says. A form open at every layer is served as before, and a form only an organization carries is still served there. - **Organization-scoped saves.** A `view` save that would re-open a public form another layer withdrew is refused with `403 NOT_OVERRIDABLE`, and the message names the remedy: publish the form from its environment-wide definition. An organization-scoped edit that keeps the form withdrawn, or that leaves its intake as it is, is still accepted. -- **`@objectstack/metadata-core`** exports the shared judgement, `anonymousFormIntakeWithdrawnIn` and `anonymousFormWithdrawnSlugs`, which both the doors and the save path read. +- **`@objectstack/metadata-core`** exports the shared judgement, `anonymousFormIntakeWithdrawnIn` and `anonymousFormWithdrawnSlugs` (new, additive public exports), which both the doors and the save path read. From 1d6bdd5faded73e10039869c6b38c938ca876155 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:11:00 +0000 Subject: [PATCH 06/19] fix(metadata): a public form withdrawal closes the same form only, and only when explicit A withdrawal is judged by view identity (name + slot) across layers: other views sharing a slug never close each other. Only a sharing that keeps the link and clears a switch withdraws; a linkless sharing (raw or schema-parsed) does not. The org-scoped write refusal judges the doors' verdict for every form the save leaves open, over the container's list-read expansion. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/anonymous-form-intake.test.ts | 58 +++++++----- .../src/anonymous-form-intake.ts | 91 ++++++++----------- .../protocol.org-scoped-write-refused.test.ts | 47 +++++++++- packages/metadata-protocol/src/protocol.ts | 68 ++++++++------ .../rest/src/public-form-withdrawal.test.ts | 40 +++++--- packages/rest/src/rest-server.ts | 15 +-- 6 files changed, 194 insertions(+), 125 deletions(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index cd518a058f5..87497652057 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -11,7 +11,6 @@ import { anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, anonymousFormIntakeWithdrawnIn, - anonymousFormWithdrawnSlugs, anonymousFormObjectName, anonymousFormSharingPath, publicFormSlug, @@ -187,45 +186,57 @@ describe('where the reason is located, and the reason itself', () => { }); }); -describe('anonymousFormIntakeWithdrawnIn — a withdrawal at any layer is a kill switch', () => { +describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same form at any layer closes it', () => { const view = (sharing: unknown, name = 'contact') => ({ name, object: 'inquiry', viewKind: 'form', config: { sharing }, }); const openView = view(OPEN); const [candidate] = anonymousFormIntakeCandidates(openView); - it('anonymousFormWithdrawnSlugs: a sharing naming a slug it does not open withdraws that slug', () => { - expect(anonymousFormWithdrawnSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual(['contact-us']); - expect(anonymousFormWithdrawnSlugs(view({ ...OPEN, enabled: false }))).toEqual(['contact-us']); - expect(anonymousFormWithdrawnSlugs(view({ allowAnonymous: true, publicLink: 'contact-us' }))).toEqual(['contact-us']); - expect(anonymousFormWithdrawnSlugs(openView)).toEqual([]); - expect(anonymousFormWithdrawnSlugs(view({ enabled: false }))).toEqual([]); - expect(anonymousFormWithdrawnSlugs(view(undefined))).toEqual([]); - }); - - it('the same view switched off at the same slot withdraws, whatever its public link says', () => { + it('the same view, same slot, the link kept with a switch cleared: withdrawn', () => { expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, allowAnonymous: false })], openView, candidate)).toBe(true); - expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false })], openView, candidate)).toBe(true); - expect(anonymousFormIntakeWithdrawnIn([view({ allowAnonymous: false, enabled: true })], openView, candidate)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, enabled: false })], openView, candidate)).toBe(true); + // A raw body with `enabled` absent reads as its parsed default (`false`). + expect(anonymousFormIntakeWithdrawnIn( + [view({ allowAnonymous: true, publicLink: '/forms/contact-us' })], openView, candidate, + )).toBe(true); }); - it('another view withdrawing the slug withdraws it', () => { - const layer = [view({ ...OPEN, enabled: false }, 'legacy_contact')]; - expect(anonymousFormIntakeWithdrawnIn(layer, openView, candidate)).toBe(true); + it('two different views sharing a slug do not close each other', () => { + const other = view({ ...OPEN, enabled: false }, 'legacy_contact'); + const [otherOpen] = anonymousFormIntakeCandidates(view(OPEN, 'legacy_contact')); + // One layer holding both: the open one stays open… + expect(anonymousFormIntakeWithdrawnIn([openView, other], openView, candidate)).toBe(false); + // …and a withdrawal of this view does not close the other view's form. + expect(anonymousFormIntakeWithdrawnIn( + [view({ ...OPEN, enabled: false }), view(OPEN, 'legacy_contact')], view(OPEN, 'legacy_contact'), otherOpen, + )).toBe(false); }); - it('a layer that opens it, or has no word on it, withdraws nothing', () => { + it('not a withdrawal: no body of the view, no sharing, the link cleared or changed', () => { expect(anonymousFormIntakeWithdrawnIn([openView], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([view(undefined)], openView, candidate)).toBe(false); - // Another view switched off at its own slot, naming no slug: not this form. - expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false }, 'other')], openView, candidate)).toBe(false); - // A different slug withdrawn elsewhere: not this form. + expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false, allowAnonymous: false })], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn( - [view({ ...OPEN, enabled: false, publicLink: '/forms/other' }, 'other')], openView, candidate, + [view({ ...OPEN, enabled: false, publicLink: '/forms/other' })], openView, candidate, )).toBe(false); }); + it('a schema-parsed sharing with no public link is not a withdrawal, as its raw body is not', () => { + for (const raw of [{ password: 'secret' }, { allowedDomains: ['example.com'] }, { enabled: true }]) { + const parsed = SharingConfigSchema.parse(raw); + // The parse fills the switches with their `false` defaults… + expect(parsed.enabled === true && parsed.allowAnonymous === true).toBe(false); + // …and neither shape names the link, so neither withdraws. + expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false); + } + // Control: a parsed sharing that keeps the link and does not open it withdraws. + const closed = SharingConfigSchema.parse({ publicLink: '/forms/contact-us', allowAnonymous: true }); + expect(anonymousFormIntakeWithdrawnIn([view(closed)], openView, candidate)).toBe(true); + }); + it('slots are matched per shape: a formViews entry is judged against the same key only', () => { const nested = (a: unknown, b: unknown) => ({ name: 'multi', object: 'inquiry', @@ -233,9 +244,8 @@ describe('anonymousFormIntakeWithdrawnIn — a withdrawal at any layer is a kill }); const opened = nested({ ...OPEN, publicLink: '/forms/a' }, { ...OPEN, publicLink: '/forms/b' }); const [ca, cb] = anonymousFormIntakeCandidates(opened); - const layer = [nested({ enabled: false }, { ...OPEN, publicLink: '/forms/b' })]; + const layer = [nested({ ...OPEN, publicLink: '/forms/a', enabled: false }, { ...OPEN, publicLink: '/forms/b' })]; expect(anonymousFormIntakeWithdrawnIn(layer, opened, ca)).toBe(true); expect(anonymousFormIntakeWithdrawnIn(layer, opened, cb)).toBe(false); }); }); - diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index d802ef3e236..c930c9b50a8 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -22,9 +22,10 @@ * * Clearing either switch withdraws the form from every anonymous door. * - * A withdrawal is a kill switch: any metadata layer the doors read that - * withdraws a form closes it, and layering may only narrow intake, never - * re-open it ({@link anonymousFormIntakeWithdrawnIn}). + * A withdrawal is a kill switch: any metadata layer the doors read whose body + * of the same view explicitly withdraws the form (the link kept, a switch + * cleared) 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 @@ -265,56 +266,43 @@ function anonymousFormSharingAtSlot(view: Record, slot: string): un return view.viewKind === 'form' ? view.config?.sharing : undefined; } -/** Every form `sharing` a `view` body carries, in scan order (open or not). */ -function anonymousFormSharings(view: unknown): unknown[] { - if (!view || typeof view !== 'object') return []; - const v = view as Record; - const out: unknown[] = []; - if (v.form && typeof v.form === 'object') out.push(v.form.sharing); - if (v.formViews && typeof v.formViews === 'object') { - for (const fv of Object.values(v.formViews)) { - if (fv && typeof fv === 'object') out.push((fv as Record).sharing); - } - } - if (v.viewKind === 'form' && v.config && typeof v.config === 'object') out.push(v.config.sharing); - return out; -} - /** - * The slugs a `view` body WITHDRAWS: a form `sharing` names the slug in its - * `publicLink` but does not open it (`enabled` or `allowAnonymous` is not - * `true`). Sorted and de-duplicated. + * Does one sharing EXPLICITLY withdraw a slug? It names the slug in its + * `publicLink` and does not open it (`enabled` or `allowAnonymous` is not + * `true`, the spec's defaults of `false` included). A sharing that names no + * public link withdraws nothing: removing the sharing block or clearing the + * link is not a withdrawal, and a code-authored, schema-parsed sharing with no + * link (whose `enabled`/`allowAnonymous` defaults read `false`) answers exactly + * as its raw body does. */ -export function anonymousFormWithdrawnSlugs(view: unknown): string[] { - const withdrawn = new Set(); - for (const sharing of anonymousFormSharings(view)) { - if (!sharing || typeof sharing !== 'object') continue; - const publicLink = (sharing as Record).publicLink; - if (typeof publicLink !== 'string' || !publicLink) continue; - if (anonymousFormIntakeSlug(sharing) !== null) continue; - withdrawn.add(publicFormSlug(publicLink)); - } - return [...withdrawn].sort(); +function sharingWithdrawsSlug(sharing: unknown, slug: string): boolean { + if (!sharing || typeof sharing !== 'object') return false; + const publicLink = (sharing as Record).publicLink; + if (typeof publicLink !== 'string' || !publicLink) return false; + if (publicFormSlug(publicLink) !== slug) return false; + return anonymousFormIntakeSlug(sharing) === null; } /** - * Does one metadata layer — the full `view` list one read answers — withdraw - * an open form candidate? A WITHDRAWAL IS A KILL SWITCH: layering may only - * narrow anonymous intake, never re-open it, so the anonymous form doors serve - * a candidate only when no layer they read withdraws it (and the - * organization-scoped write door refuses a write that would re-open one). - * - * The layer withdraws it when either holds: + * Does one metadata layer — the `view` list one read answers — withdraw an + * open form candidate? A WITHDRAWAL IS A KILL SWITCH: layering may only narrow + * anonymous intake, never re-open it, so the anonymous form doors serve a + * candidate only when no layer they read withdraws it (and the + * organization-scoped write door refuses a save that would leave one open). * - * - some view in the layer names the candidate's slug in a `sharing` it does - * not open ({@link anonymousFormWithdrawnSlugs}) — the slug is withdrawn, - * whichever view withdrew it; - * - the layer's own body of the same view (by `name`) switches the form at the - * same slot off: `sharing.enabled === false` or `sharing.allowAnonymous === - * false`, whatever its `publicLink` says. + * A withdrawal closes THE SAME FORM, never another: the layer withdraws the + * candidate only when its own body of the same view (by `name`) carries, at + * the same slot (the nested `form`, the same `formViews` key, or the flattened + * `config`), a sharing that names the candidate's slug and does not open it + * (`enabled: false` or `allowAnonymous: false` with the link kept). Another + * view that publishes or withdraws the same slug is a different form and does + * not close this one. * - * A layer with no body of the view and no word on the slug withdraws nothing, - * so a form published only in an organization stays open there. + * Only an explicit withdrawal closes. A layer with no body of the view, a body + * with no sharing at that slot, a sharing with no public link, or one that + * names another slug withdraws nothing: deleting the view, removing its + * sharing or clearing (or changing) its public link at a layer is not a + * withdrawal, so a form published only in an organization stays open there. */ export function anonymousFormIntakeWithdrawnIn( layer: ReadonlyArray, @@ -323,17 +311,14 @@ export function anonymousFormIntakeWithdrawnIn( ): boolean { if (!view || typeof view !== 'object') return false; const v = view as Record; - const slot = anonymousFormSlot(v, candidate); const name = typeof v.name === 'string' && v.name ? v.name : undefined; + if (name === undefined) return false; + const slot = anonymousFormSlot(v, candidate); for (const other of layer) { if (!other || typeof other !== 'object') continue; - if (anonymousFormWithdrawnSlugs(other).includes(candidate.slug)) return true; const o = other as Record; - if (name === undefined || o.name !== name) continue; - const sharing = anonymousFormSharingAtSlot(o, slot); - if (!sharing || typeof sharing !== 'object') continue; - const s = sharing as Record; - if (s.enabled === false || s.allowAnonymous === false) return true; + if (o.name !== name) continue; + if (sharingWithdrawsSlug(anonymousFormSharingAtSlot(o, slot), candidate.slug)) return true; } return false; } 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 cd5bd9d8416..46d9bfffcbf 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 @@ -698,7 +698,7 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', }); await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); - await expect(refusal).rejects.toThrow(/cannot re-open public form '\/forms\/walled-intake'/); + await expect(refusal).rejects.toThrow(/cannot keep public form '\/forms\/walled-intake' open/); expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); }); @@ -727,6 +727,51 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se ]); }); + it('single: re-saving an org overlay that was open before the env-wide withdrawal is refused', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + await publishEnvWide(protocol); + // Open in the organization while open env-wide: accepted. + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).success).toBe(true); + // Then withdrawn env-wide (the link kept, anonymous access cleared). + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) })).success) + .toBe(true); + const before = orgRows(rows).filter((r) => r.org === 'org_a'); + // A re-save of the still-open overlay (only its label changes) would + // leave open a form the env-wide layer withdrew: refused. + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (renamed)'), organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual(before); + }); + + it('single: a container-shaped org save is judged as the list read expands it', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + const container = (allowAnonymous: boolean) => ({ + name: 'task', object: 'task', formViews: { intake_form: { sharing: sharing(allowAnonymous) } }, + }); + expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: container(false) })).success).toBe(true); + const refusal = protocol.saveMetaItem({ type: 'view', name: 'task', item: container(true), organizationId: 'org_a' }); + await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + await expect(refusal).rejects.toThrow(/cannot keep public form '\/forms\/walled-intake' open/); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + // Control: the same container kept withdrawn in the organization saves. + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: container(false), organizationId: 'org_a', + })).success).toBe(true); + }); + + it('control (single): a sharing with no public link env-wide is not a withdrawal', async () => { + const { protocol } = makeTenancyProtocol('org_a'); + const linkless = FORM_VIEW(true); + linkless.config.sharing = { enabled: false, allowAnonymous: false } as any; + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: linkless })).success).toBe(true); + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).success).toBe(true); + }); + it('control (single): an org-scoped republish over an env-wide published form still saves', async () => { const { protocol } = makeTenancyProtocol('org_a'); await publishEnvWide(protocol); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 452f2227d99..c4a6e349af9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15723,6 +15723,8 @@ export class ObjectStackProtocolImplementation implements name: string; organizationId: string | null | undefined; body: unknown; + /** The package binding the row is saved under (a container's expansion is placed by it). */ + packageId?: string | null; }): Promise { if (!args.organizationId) return null; const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; @@ -15762,58 +15764,67 @@ export class ObjectStackProtocolImplementation implements /** * An organization-scoped `view` write, in the organization the anonymous - * form doors read, that would re-open a public form the env-wide layer + * form doors read, that would leave open a public form the env-wide layer * withdrew. Returns the refusal, or `null` when the write is fine. * - * A withdrawal is a kill switch: any layer that withdraws a public form's - * intake (or its anonymous access) closes it, and layering may only narrow - * intake, never re-open it. The doors enforce that at read time + * A withdrawal is a kill switch: an explicit withdrawal of a public form + * (the same view, the same slot, the link kept with `enabled` or + * `allowAnonymous` cleared) at any layer closes it, and layering may only + * narrow intake, never re-open it. The doors enforce that at read time * (`registerFormEndpoints` in `@objectstack/rest` reads the env-wide layer - * beneath the organization's and lets either withdraw), so such a write - * would be accepted and then never honoured. It is refused instead, and the - * author is pointed at the env-wide definition, which is the switch. - * - * Judged by the doors' own predicate ({@link anonymousFormIntakeWithdrawnIn}) - * over the env-wide `view` list, and only for a slug this write opens that - * the organization's current definition does not: an edit that leaves the - * organization's intake as it is (or withdraws it) is never refused here. + * beneath the organization's and lets its withdrawal close the form), so + * such a write would be accepted and then never honoured. It is refused + * instead, and the author is pointed at the env-wide definition, which is + * the switch. + * + * Judged by the doors' own verdict ({@link anonymousFormIntakeWithdrawnIn}) + * over the env-wide `view` list, for every form this write would leave + * open — whether or not the organization's current definition has it open + * already, so re-saving an overlay that was open before the env-wide + * withdrawal is refused too. The body is judged as the list read serves + * it: a container-shaped body (`formViews`, `form`, …) is expanded into + * the view items the doors read ({@link expandRuntimeViewContainer}). An + * organization-scoped save that keeps the form withdrawn, or that opens + * nothing the env-wide layer withdrew, is never refused here. */ private async anonymousFormIntakeReopenRefusal(args: { type: string; name: string; organizationId: string; body: unknown; + packageId?: string | null; }): Promise { - if (!args.body || typeof args.body !== 'object') return null; - const body = { ...(args.body as Record), name: args.name }; - const proposed = anonymousFormIntakeCandidates(body); - if (proposed.length === 0) return null; - const current = new Set(anonymousFormIntakeSlugs( - ((await this.getMetaItem({ type: args.type, name: args.name, organizationId: args.organizationId })) as any) - ?.item, - )); - const opening = proposed.filter((c) => !current.has(c.slug)); - if (opening.length === 0) return null; + if (!args.body || typeof args.body !== 'object' || Array.isArray(args.body)) return null; + const raw = args.body as Record; + // The name stamp the container-collision judge applies (a body with no + // `name` is a container under the save name); a view item is the save + // name's own row. + const stamped = raw.name ? raw : { ...raw, name: args.name }; + const served: unknown[] = isAggregatedViewContainer(stamped) + ? this.expandRuntimeViewContainer(args.type, stamped, { packageId: args.packageId ?? undefined }) + : [{ ...raw, name: args.name }]; + const open = served.flatMap((view) => anonymousFormIntakeCandidates(view).map((c) => ({ view, c }))); + if (open.length === 0) return null; const envWide: any = await this.getMetaItems({ type: args.type }); const layer: unknown[] = Array.isArray(envWide?.items) ? envWide.items : []; const reopened = [...new Set( - opening.filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)).map((c) => c.slug), + open.filter(({ view, c }) => anonymousFormIntakeWithdrawnIn(layer, view, c)).map(({ c }) => c.slug), )].sort(); if (reopened.length === 0) return null; const list = reopened.map((s) => `'/forms/${s}'`).join(', '); const err: any = new Error( - `Metadata item 'view/${args.name}' cannot re-open public form ${list} for anonymous intake ` + `Metadata item 'view/${args.name}' cannot keep public form ${list} open for anonymous intake ` + `in organization '${args.organizationId}': the env-wide definition withdraws it. A withdrawal is ` + `a kill switch, so an organization overlay may narrow a public form's intake but never re-open it, ` - + `and the anonymous form doors would keep answering it as not found. To publish it again, save the ` - + `form env-wide (retry with no active organization) with sharing enabled and anonymous access ` - + `allowed. An organization-scoped edit that keeps this form withdrawn is still accepted. ` + + `and the anonymous form doors keep answering it as not found. Save this overlay with the form's ` + + `sharing withdrawn (enabled or allowAnonymous false), or, to publish the form again, save it ` + + `env-wide (retry with no active organization) with sharing enabled and anonymous access allowed. ` + `See docs/adr/0005-metadata-customization-overlay.md.` ); err.code = 'NOT_OVERRIDABLE'; err.status = 403; // The sentence an end user is shown (the producer-declared channel). - err.userMessage = `This public form was withdrawn for the whole environment, so it cannot be re-opened ` + err.userMessage = `This public form was withdrawn for the whole environment, so it cannot be open ` + `for one organization. Publish it again from the environment-wide form definition.`; err.organizationId = args.organizationId; err.docs = 'docs/adr/0005-metadata-customization-overlay.md'; @@ -19174,6 +19185,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, organizationId: request.organizationId, body: request.item, + ...(request.packageId ? { packageId: request.packageId } : {}), }); if (intakeRefusal) throw intakeRefusal; } diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 1b67cfe6d31..ffbf21bbf76 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -17,13 +17,16 @@ // - tenancy never registered: the env-wide read, unchanged; // - tenancy registered but unreachable: both doors refuse (fail closed). // -// A withdrawal is a kill switch: any layer the doors read that withdraws the -// form closes it, and layering may only narrow intake, never re-open it. The -// doors therefore read the env-wide layer beneath the organization's too: +// A withdrawal is a kill switch: an explicit withdrawal of the same form (the +// same view and slot, the link kept with a switch cleared) at any layer the +// doors read closes it, and layering may only narrow intake, never re-open it. +// The doors therefore read the env-wide layer beneath the organization's too: // - withdrawn env-wide, re-opened in the organization: both doors refuse; // - withdrawn in the organization, open env-wide: both doors refuse; // - open in both: both doors accept; -// - a form only the organization has (no env-wide body): it is served. +// - a form only the organization has (no env-wide body): it is served; +// - not a withdrawal (the link cleared env-wide, or another view sharing the +// slug withdrawn): it is served. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -259,14 +262,18 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); }); - it('switched off env-wide with its public link cleared, re-opened in the organization: both doors refuse', async () => { - const withdrawn = formView(true, { enabled: true, allowAnonymous: false }); - await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); + it('not a withdrawal: the public link cleared env-wide leaves the organization\'s open form served', async () => { + const cleared = formView(true, { enabled: true, allowAnonymous: false }); + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [cleared] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); }); - it('the slug withdrawn env-wide by another view: an organization view opening it is refused', async () => { + it('another view withdrawing the same slug env-wide does not close this view\'s form', async () => { const other = { ...formView(false), name: 'legacy_contact' }; - await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] })); + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); }); it('withdrawn in the organization, open env-wide: both doors refuse', async () => { @@ -286,8 +293,15 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect((await s.post()).statusCode).toBe(201); }); - it('one read: another view withdrawing the same slug closes it with no organization to resolve', async () => { - const other = { ...formView(false), name: 'legacy_contact' }; - await expectClosed(build({ envWide: true, tenancy: 'no-org', extraViews: [other] })); - }); + for (const tenancy of ['org', 'no-org'] as const) { + it(`two different views sharing a slug in one read do not close each other (tenancy ${tenancy})`, async () => { + // Another view (another app's form) names the same slug and withdraws + // it, in every read: this view's open form is still served. + const other = { ...formView(false), name: 'legacy_contact' }; + const s = build({ envWide: true, tenancy, extraViews: [other], ...(tenancy === 'org' ? { inOrg: true } : {}) }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); + }); + } }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3f30cb52296..3151ae9c0a7 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10718,9 +10718,10 @@ export class RestServer { // // A withdrawal is a kill switch: layering may only narrow anonymous // intake, never re-open it. A candidate is served only when no layer - // the doors read withdraws it (`anonymousFormIntakeWithdrawnIn`): the - // read the form is found in, and, when that read is an organization's, - // the env-wide read beneath it. + // beneath the read it is found in explicitly withdraws the same form + // (`anonymousFormIntakeWithdrawnIn`: the same view, the same slot, the + // link kept with a switch cleared). Another view publishing the same + // slug is a different form and closes nothing. const findPublicFormView = ( views: any[], slug: string, @@ -10793,9 +10794,11 @@ export class RestServer { // The organization read prefers the organization's overlay of a // view over the env-wide one, so on its own it cannot see an // env-wide withdrawal that overlay disagrees with. Read the - // env-wide layer too, and let either layer's withdrawal close the - // form: an organization overlay can narrow intake, never re-open it. - const layers: any[][] = [items]; + // env-wide layer too, and let its withdrawal of the same form close + // it: an organization overlay can narrow intake, never re-open it. + // (The read the form is found in holds only that view's own body, + // which is open, so it withdraws nothing of its own.) + const layers: any[][] = []; if (organizationId) { const envWideRequest: TransportScopedMetaRequest = { type: 'view', From cfc55af7be623ae5ab85af17fdde46be4b436c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:19:11 +0000 Subject: [PATCH 07/19] docs: what withdraws a public form, and what does not Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/public-form-withdrawal-kill-switch.md | 10 ++++++---- content/docs/ui/public-data-collection.mdx | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index 0f3fd5917c8..f4f4ac83a49 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -4,10 +4,12 @@ '@objectstack/metadata-core': minor --- -A public form's intake withdrawal at any metadata layer now holds: layering can only narrow anonymous intake, never re-open it +A public form's explicit intake withdrawal at any metadata layer now holds: layering can only narrow anonymous intake, never re-open it Clause-②: yes (widening) -- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a public form only when no metadata layer they read withdraws it. A form withdrawn at one layer (`sharing.enabled: false` or `sharing.allowAnonymous: false`, or its public link named by a sharing that does not open it) answers `404 FORM_NOT_FOUND` on both doors and creates no record, whatever another layer says. A form open at every layer is served as before, and a form only an organization carries is still served there. -- **Organization-scoped saves.** A `view` save that would re-open a public form another layer withdrew is refused with `403 NOT_OVERRIDABLE`, and the message names the remedy: publish the form from its environment-wide definition. An organization-scoped edit that keeps the form withdrawn, or that leaves its intake as it is, is still accepted. -- **`@objectstack/metadata-core`** exports the shared judgement, `anonymousFormIntakeWithdrawnIn` and `anonymousFormWithdrawnSlugs` (new, additive public exports), which both the doors and the save path read. +- **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. It closes the same form only: the same view, at the same place in it (`form`, the same `formViews` entry, or its `config`), across its metadata layers. A different view that uses the same public slug is a different form, and the two never close each other. Removing the `sharing` block, clearing or changing the `publicLink`, or deleting the view at one layer is not a withdrawal. A sharing that names no public link withdraws nothing, whether it is a raw body or a schema-parsed one whose `enabled`/`allowAnonymous` defaults read `false`. +- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a form only when no layer they read withdraws it. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record, whatever another layer says. A form that is open at every layer is served as before. A form that only an organization carries is still served there. +- **Behaviour change.** Before this release, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That is reversed on purpose. The environment-wide withdrawal now wins, and the organization's copy cannot re-open the form. +- **Organization-scoped saves.** A `view` save in the organization the doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This holds even when the organization's copy was already open before the withdrawal, so re-saving that overlay is refused too. A container-shaped body (`formViews`, `form`) is judged the way the list read expands it. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. Rollback and commit-revert restores are not gated by this judgement yet. The doors still keep such a form closed. +- **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 9700c47e95a..b6fc25aea2b 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -52,6 +52,17 @@ System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation. The public path only ever **inserts**; it never lists. +### 4. Withdraw a public form + +To stop taking submissions, keep the form's `publicLink` and clear a switch: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created. + +A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again: the endpoints keep answering not found, and an organization-scoped save that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. + +Only an explicit withdrawal closes the form: + +- **It closes the same form only.** A withdrawal applies to the view that carries it, at the same place in that view (`form`, the same `formViews` entry, or the view's own `config`), across its layers. A different view that uses the same public link (for example, another app's "contact us" form) is a separate form. It neither closes this one nor is closed by it. +- **Removing is not withdrawing.** Removing the `sharing` block, clearing or changing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. To close a form for good, keep the link and set `enabled: false` or `allowAnonymous: false`. + ## Why Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form. There is no standing "anonymous can write to this object" rule to misconfigure: the only thing the public can do is create one record through one whitelisted form. This is the difference from Airtable, where interfaces can't be shared publicly at all (only forms can) — here the same FormView metadata renders both internally (authed) and publicly (anonymous) through one renderer. From e8778acb960387682072d73d0a235bda16bb2261 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:19:32 +0000 Subject: [PATCH 08/19] test(dogfood): re-saving an org overlay open from before an env-wide withdrawal is refused Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- ...blic-form-withdrawal-layers.dogfood.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index 23385e07218..449ea548bfa 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // A public form withdrawal is a kill switch, on a real showcase boot: any -// metadata layer that withdraws a public form's intake closes it, and layering -// may only narrow intake, never re-open it. +// metadata layer whose body of the same view explicitly withdraws the form's +// intake (the link kept, a switch cleared) closes it, and layering may only +// narrow intake, never re-open it. // // The showcase ships `showcase_inquiry.contact`, a FormView open to anonymous // intake at `/forms/contact-us`. The administrator saves it the way the editor @@ -13,7 +14,8 @@ // - an organization overlay that keeps the form open does not survive an // env-wide withdrawal: both anonymous doors answer `404 FORM_NOT_FOUND` // and nothing lands; -// - an organization-scoped save that would re-open it is refused +// - an organization-scoped save that would leave it open (a re-save of +// the overlay open from before, or a re-open) is refused // (`403 NOT_OVERRIDABLE`) and the doors stay closed; // - withdrawn in the organization while open env-wide: closed; // - open at both layers (control): both doors accept. @@ -107,10 +109,14 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => expect(await probe()).toEqual(CLOSED); }); - it('an organization-scoped save that would re-open it is refused, and the doors stay closed', async () => { + it('an organization-scoped save that would leave it open is refused, and the doors stay closed', async () => { await scope(organizationId); - // The organization overlay is still open, so withdraw it there first; the - // re-open is then this write's own doing. + // The organization overlay is still open from before the withdrawal: + // re-saving it as it is would leave open a withdrawn form. + const resave = await save(true); + expect(resave.status, JSON.stringify(resave.json)).toBe(403); + expect(resave.json.code ?? resave.json.error?.code).toBe('NOT_OVERRIDABLE'); + // Withdrawing it there is accepted; re-opening it is refused again. expect((await save(false)).status).toBe(200); const reopen = await save(true); expect(reopen.status, JSON.stringify(reopen.json)).toBe(403); From 6663acc97fa1c4507dd4f4d45b8dfde15ded7dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:29:17 +0000 Subject: [PATCH 09/19] fix(metadata): a withdrawal is explicit false only, matched by slot or slug, and the write door anchors identity on the stored row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/anonymous-form-intake.test.ts | 74 +++++++++------ .../src/anonymous-form-intake.ts | 91 ++++++++++--------- .../protocol.org-scoped-write-refused.test.ts | 63 +++++++++++++ packages/metadata-protocol/src/protocol.ts | 54 ++++++++++- .../rest/src/public-form-withdrawal.test.ts | 35 +++++-- 5 files changed, 241 insertions(+), 76 deletions(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index 87497652057..dfb4141fe3d 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -186,66 +186,88 @@ describe('where the reason is located, and the reason itself', () => { }); }); -describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same form at any layer closes it', () => { +describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same row\'s form at any layer closes it', () => { const view = (sharing: unknown, name = 'contact') => ({ name, object: 'inquiry', viewKind: 'form', config: { sharing }, }); const openView = view(OPEN); const [candidate] = anonymousFormIntakeCandidates(openView); - it('the same view, same slot, the link kept with a switch cleared: withdrawn', () => { + it('the same row, the link kept with a switch explicitly false: withdrawn', () => { expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, allowAnonymous: false })], openView, candidate)).toBe(true); expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, enabled: false })], openView, candidate)).toBe(true); - // A raw body with `enabled` absent reads as its parsed default (`false`). + }); + + it('only an explicit false withdraws: an absent switch is not a withdrawal', () => { + // publicLink + enabled:true, allowAnonymous absent: not a withdrawal. + expect(anonymousFormIntakeWithdrawnIn( + [view({ enabled: true, publicLink: '/forms/contact-us' })], openView, candidate, + )).toBe(false); expect(anonymousFormIntakeWithdrawnIn( [view({ allowAnonymous: true, publicLink: '/forms/contact-us' })], openView, candidate, - )).toBe(true); + )).toBe(false); }); - it('two different views sharing a slug do not close each other', () => { + it('two different rows sharing a slug do not close each other', () => { const other = view({ ...OPEN, enabled: false }, 'legacy_contact'); - const [otherOpen] = anonymousFormIntakeCandidates(view(OPEN, 'legacy_contact')); - // One layer holding both: the open one stays open… expect(anonymousFormIntakeWithdrawnIn([openView, other], openView, candidate)).toBe(false); - // …and a withdrawal of this view does not close the other view's form. + const [otherOpen] = anonymousFormIntakeCandidates(view(OPEN, 'legacy_contact')); expect(anonymousFormIntakeWithdrawnIn( [view({ ...OPEN, enabled: false }), view(OPEN, 'legacy_contact')], view(OPEN, 'legacy_contact'), otherOpen, )).toBe(false); }); - it('not a withdrawal: no body of the view, no sharing, the link cleared or changed', () => { + it('not a withdrawal: no body of the row, no sharing, the link cleared', () => { expect(anonymousFormIntakeWithdrawnIn([openView], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([view(undefined)], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false, allowAnonymous: false })], openView, candidate)).toBe(false); - expect(anonymousFormIntakeWithdrawnIn( - [view({ ...OPEN, enabled: false, publicLink: '/forms/other' })], openView, candidate, - )).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, enabled: false, publicLink: '' })], openView, candidate)).toBe(false); }); it('a schema-parsed sharing with no public link is not a withdrawal, as its raw body is not', () => { for (const raw of [{ password: 'secret' }, { allowedDomains: ['example.com'] }, { enabled: true }]) { const parsed = SharingConfigSchema.parse(raw); - // The parse fills the switches with their `false` defaults… expect(parsed.enabled === true && parsed.allowAnonymous === true).toBe(false); - // …and neither shape names the link, so neither withdraws. expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false); } - // Control: a parsed sharing that keeps the link and does not open it withdraws. - const closed = SharingConfigSchema.parse({ publicLink: '/forms/contact-us', allowAnonymous: true }); - expect(anonymousFormIntakeWithdrawnIn([view(closed)], openView, candidate)).toBe(true); }); - it('slots are matched per shape: a formViews entry is judged against the same key only', () => { - const nested = (a: unknown, b: unknown) => ({ - name: 'multi', object: 'inquiry', - formViews: { a: { sharing: a }, b: { sharing: b } }, + it('the same slot with a new slug (case-only included) is the same form: closed', () => { + const withdrawn = [view({ ...OPEN, enabled: false })]; + for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { + const moved = view({ ...OPEN, publicLink: link }); + const [c] = anonymousFormIntakeCandidates(moved); + expect(anonymousFormIntakeWithdrawnIn(withdrawn, moved, c)).toBe(true); + } + }); + + describe('a container row: identity survives a key rename, form.name, a slot move and an expansion rename', () => { + const LINK_A = { ...OPEN, publicLink: '/forms/a' }; + const LINK_B = { ...OPEN, publicLink: '/forms/b' }; + const row = (body: Record) => ({ name: 'inquiry', object: 'inquiry', ...body }); + // Env-wide: formViews.a withdrawn, formViews.b open. + const envRow = row({ formViews: { a: { sharing: { ...LINK_A, enabled: false } }, b: { sharing: LINK_B } } }); + const closedIn = (overlay: Record) => + anonymousFormIntakeCandidates(overlay) + .filter((c) => anonymousFormIntakeWithdrawnIn([envRow], overlay, c)) + .map((c) => c.slug); + + it('a key rename keeps the slug: closed', () => { + expect(closedIn(row({ formViews: { a2: { sharing: LINK_A }, b: { sharing: LINK_B } } }))).toEqual(['a']); + }); + it('a slot move to the nested form, with a form.name: closed', () => { + expect(closedIn(row({ form: { name: 'renamed', sharing: LINK_A }, formViews: { b: { sharing: LINK_B } } }))) + .toEqual(['a']); + }); + it('a listViews entry that collides with the key (an expansion rename): closed', () => { + expect(closedIn(row({ listViews: { a: { type: 'grid' } }, formViews: { a: { sharing: LINK_A } } }))) + .toEqual(['a']); + }); + it('the sibling form (another slot and another slug) stays independent', () => { + expect(closedIn(row({ formViews: { a: { sharing: { ...LINK_A, enabled: false } }, b: { sharing: LINK_B } } }))) + .toEqual([]); }); - const opened = nested({ ...OPEN, publicLink: '/forms/a' }, { ...OPEN, publicLink: '/forms/b' }); - const [ca, cb] = anonymousFormIntakeCandidates(opened); - const layer = [nested({ ...OPEN, publicLink: '/forms/a', enabled: false }, { ...OPEN, publicLink: '/forms/b' })]; - expect(anonymousFormIntakeWithdrawnIn(layer, opened, ca)).toBe(true); - expect(anonymousFormIntakeWithdrawnIn(layer, opened, cb)).toBe(false); }); }); diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index c930c9b50a8..eb244c9ecbf 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -248,8 +248,7 @@ export function anonymousFormIntakeUnavailableMessage(slug: string, u: Anonymous /** * Where a form sits in a `view` body, independent of its content: the nested - * `form`, one `formViews` entry, or the flattened `config`. Two bodies of the - * same view (one per metadata layer) carry "the same form" at the same slot. + * `form`, one `formViews` entry, or the flattened `config`. */ function anonymousFormSlot(view: Record, candidate: AnonymousFormIntakeCandidate): string { if (candidate.form === view.form) return 'form'; @@ -259,50 +258,57 @@ function anonymousFormSlot(view: Record, candidate: AnonymousFormIn return 'config'; } -/** The `sharing` a `view` body carries at a slot (see {@link anonymousFormSlot}), if any. */ -function anonymousFormSharingAtSlot(view: Record, slot: string): unknown { - if (slot === 'form') return view.form?.sharing; - if (slot.startsWith('formViews:')) return view.formViews?.[slot.slice('formViews:'.length)]?.sharing; - return view.viewKind === 'form' ? view.config?.sharing : undefined; -} - /** - * Does one sharing EXPLICITLY withdraw a slug? It names the slug in its - * `publicLink` and does not open it (`enabled` or `allowAnonymous` is not - * `true`, the spec's defaults of `false` included). A sharing that names no - * public link withdraws nothing: removing the sharing block or clearing the - * link is not a withdrawal, and a code-authored, schema-parsed sharing with no - * link (whose `enabled`/`allowAnonymous` defaults read `false`) answers exactly - * as its raw body does. + * The forms a `view` body EXPLICITLY withdraws, with their slot and slug: a + * form `sharing` that keeps a non-empty `publicLink` and sets `enabled === + * false` or `allowAnonymous === false`. Judged on the body as stored: a switch + * that is absent is not a withdrawal (only an explicit `false` is), and a + * sharing with no public link withdraws nothing — removing the sharing block or + * clearing the link is not a withdrawal. */ -function sharingWithdrawsSlug(sharing: unknown, slug: string): boolean { - if (!sharing || typeof sharing !== 'object') return false; - const publicLink = (sharing as Record).publicLink; - if (typeof publicLink !== 'string' || !publicLink) return false; - if (publicFormSlug(publicLink) !== slug) return false; - return anonymousFormIntakeSlug(sharing) === null; +function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; slug: string }> { + if (!view || typeof view !== 'object') return []; + const v = view as Record; + const forms: Array<{ slot: string; form: unknown }> = []; + if (v.form && typeof v.form === 'object') forms.push({ slot: 'form', form: v.form }); + if (v.formViews && typeof v.formViews === 'object') { + for (const [key, fv] of Object.entries(v.formViews)) forms.push({ slot: `formViews:${key}`, form: fv }); + } + if (v.viewKind === 'form' && v.config && typeof v.config === 'object') forms.push({ slot: 'config', form: v.config }); + const out: Array<{ slot: string; slug: string }> = []; + for (const { slot, form } of forms) { + const sharing = form && typeof form === 'object' ? (form as Record).sharing : undefined; + if (!sharing || typeof sharing !== 'object') continue; + const s = sharing as Record; + if (typeof s.publicLink !== 'string' || !s.publicLink) continue; + if (s.enabled !== false && s.allowAnonymous !== false) continue; + out.push({ slot, slug: publicFormSlug(s.publicLink) }); + } + return out; } /** - * Does one metadata layer — the `view` list one read answers — withdraw an - * open form candidate? A WITHDRAWAL IS A KILL SWITCH: layering may only narrow - * anonymous intake, never re-open it, so the anonymous form doors serve a - * candidate only when no layer they read withdraws it (and the - * organization-scoped write door refuses a save that would leave one open). + * Does one metadata layer withdraw an open form candidate? A WITHDRAWAL IS A + * KILL SWITCH: layering may only narrow anonymous intake, never re-open it, so + * the anonymous form doors serve a candidate only when no layer beneath it + * withdraws it, and the organization-scoped write door refuses a save that + * would leave one open. * - * A withdrawal closes THE SAME FORM, never another: the layer withdraws the - * candidate only when its own body of the same view (by `name`) carries, at - * the same slot (the nested `form`, the same `formViews` key, or the flattened - * `config`), a sharing that names the candidate's slug and does not open it - * (`enabled: false` or `allowAnonymous: false` with the link kept). Another - * view that publishes or withdraws the same slug is a different form and does - * not close this one. + * Identity is the stored row: `layer` holds bodies of rows, and the candidate's + * `view` is a body of a row of the same `name` — the row its overlay is keyed + * by. The layer withdraws the candidate when its body of that row EXPLICITLY + * withdraws a form ({@link anonymousFormExplicitWithdrawals}) that matches the + * candidate by slot (the same `form`, `formViews` key or `config`) OR by slug + * (the same public link, compared exactly as the doors resolve it). Either + * match is enough, so the same form cannot escape a withdrawal by moving to + * another slot or key, or by pointing its link at a new slug; only a form that + * differs from every withdrawn one in both is a different form (a sibling in + * the same container). * - * Only an explicit withdrawal closes. A layer with no body of the view, a body - * with no sharing at that slot, a sharing with no public link, or one that - * names another slug withdraws nothing: deleting the view, removing its - * sharing or clearing (or changing) its public link at a layer is not a - * withdrawal, so a form published only in an organization stays open there. + * Another row that publishes or withdraws the same slug is a different form + * and closes nothing. A layer with no body of the row, or whose body has no + * explicit withdrawal, withdraws nothing, so a form published only in an + * organization stays open there. */ export function anonymousFormIntakeWithdrawnIn( layer: ReadonlyArray, @@ -316,9 +322,10 @@ export function anonymousFormIntakeWithdrawnIn( const slot = anonymousFormSlot(v, candidate); for (const other of layer) { if (!other || typeof other !== 'object') continue; - const o = other as Record; - if (o.name !== name) continue; - if (sharingWithdrawsSlug(anonymousFormSharingAtSlot(o, slot), candidate.slug)) return true; + if ((other as Record).name !== name) continue; + for (const w of anonymousFormExplicitWithdrawals(other)) { + if (w.slot === slot || w.slug === candidate.slug) return true; + } } return false; } 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 46d9bfffcbf..79c7e86cb06 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 @@ -762,6 +762,69 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se })).success).toBe(true); }); + describe('single: identity is the stored row, so moving the form inside its row does not escape', () => { + const LINK = '/forms/walled-intake'; + const open = { enabled: true, allowAnonymous: true, publicLink: LINK }; + const withdrawnRow = { + name: 'task', object: 'task', + formViews: { intake_form: { sharing: { ...open, allowAnonymous: false } } }, + }; + const overlays: Array<[string, Record]> = [ + ['a formViews key rename', { name: 'task', object: 'task', formViews: { intake_v2: { sharing: open } } }], + ['a move to the nested form with a form.name rename', + { name: 'task', object: 'task', form: { name: 'renamed_intake', sharing: open } }], + ['a listViews collision that makes the expansion rename it', + { name: 'task', object: 'task', listViews: { intake_form: { type: 'grid' } }, formViews: { intake_form: { sharing: open } } }], + ['the same key re-pointed at a new slug', + { name: 'task', object: 'task', formViews: { intake_form: { sharing: { ...open, publicLink: '/forms/walled-intake-2' } } } }], + ['the same key re-pointed at a case-only variant', + { name: 'task', object: 'task', formViews: { intake_form: { sharing: { ...open, publicLink: '/forms/Walled-Intake' } } } }], + ]; + for (const [label, overlay] of overlays) { + it(`${label}: refused and nothing is saved`, async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: withdrawnRow })).success).toBe(true); + await expect(protocol.saveMetaItem({ type: 'view', name: 'task', item: overlay, organizationId: 'org_a' })) + .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + } + + it('control: a sibling form in another slot with another slug still saves', async () => { + const { protocol } = makeTenancyProtocol('org_a'); + expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: withdrawnRow })).success).toBe(true); + const sibling = { + name: 'task', object: 'task', + formViews: { + intake_form: { sharing: { ...open, allowAnonymous: false } }, + feedback: { sharing: { ...open, publicLink: '/forms/feedback' } }, + }, + }; + expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: sibling, organizationId: 'org_a' })).success) + .toBe(true); + }); + }); + + it('single: the same view item re-pointed at a new slug is refused', async () => { + const { protocol } = makeTenancyProtocol('org_a'); + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) })).success).toBe(true); + const moved = FORM_VIEW(true); + moved.config.sharing.publicLink = '/forms/walled-intake-2'; + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: moved, organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('control (single): only an explicit false withdraws — allowAnonymous absent env-wide is not a withdrawal', async () => { + const { protocol } = makeTenancyProtocol('org_a'); + const absent = FORM_VIEW(true); + delete (absent.config.sharing as any).allowAnonymous; + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: absent })).success).toBe(true); + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).success).toBe(true); + }); + it('control (single): a sharing with no public link env-wide is not a withdrawal', async () => { const { protocol } = makeTenancyProtocol('org_a'); const linkless = FORM_VIEW(true); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c4a6e349af9..7a7d61f0f67 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -15807,9 +15807,22 @@ export class ObjectStackProtocolImplementation implements if (open.length === 0) return null; const envWide: any = await this.getMetaItems({ type: args.type }); const layer: unknown[] = Array.isArray(envWide?.items) ? envWide.items : []; - const reopened = [...new Set( + const closed = new Set( open.filter(({ view, c }) => anonymousFormIntakeWithdrawnIn(layer, view, c)).map(({ c }) => c.slug), - )].sort(); + ); + // The same judgement anchored on the stored ROW this overlay is keyed + // by: the env-wide body of row `name`, as stored (a container is not + // expanded, so a form moved to another key or slot, renamed through + // `form.name`, or renamed by an expansion collision is still matched + // against the form it was, by slot or by slug). + const envRows = (await this.envWideRawViewRows(args.type, args.name)).map((r) => ({ ...r, name: args.name })); + if (envRows.length > 0) { + const own = { ...raw, name: args.name }; + for (const c of anonymousFormIntakeCandidates(own)) { + if (anonymousFormIntakeWithdrawnIn(envRows, own, c)) closed.add(c.slug); + } + } + const reopened = [...closed].sort(); if (reopened.length === 0) return null; const list = reopened.map((s) => `'/forms/${s}'`).join(', '); const err: any = new Error( @@ -15831,6 +15844,33 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * The env-wide body of the `view` row `name`, as stored: the active + * env-wide `sys_metadata` row when there is one (the env-wide overlay is + * keyed by its own name, ADR-0005), else the code package's artifact of + * that name. Empty when neither exists. + * + * Read raw, never through the list read: that serves a container only as + * its expansion, whose item names and slots the overlay author chooses, + * and the kill switch anchors identity on the row instead. + */ + private async envWideRawViewRows(type: string, name: string): Promise[]> { + let records: any[] = []; + try { + records = await this.readActiveOverlayRows({ type }, undefined); + } catch (error) { + // [#5532] Only an unprovisioned store means "no rows". + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + } + const stored = this.storedOverlayEntries({ type }, records) + .filter((e) => e.name === name && e.organizationId === null) + .map((e) => e.data) + .filter((d): d is Record => !!d && typeof d === 'object' && !Array.isArray(d)); + if (stored.length > 0) return stored; + const artifact = this.lookupArtifactItem(type, name); + return artifact && typeof artifact === 'object' ? [artifact as Record] : []; + } + /** * Does an artifact (npm-package-loaded) item exist at `(type, name)`? * @@ -21193,11 +21233,21 @@ export class ObjectStackProtocolImplementation implements // The promotion half of {@link anonymousFormIntakeOrgScopeRefusal}: a // draft saved before that refusal existed must not reach `active`. if (draftForGate) { + // The binding the promoted row is placed by: the request's, else the + // draft row's own (a container's expansion is placed by it). + let draftPackageId: string | null | undefined = request.packageId; + if (draftPackageId === undefined && singularType === 'view' && orgId) { + const draftRow = await this.engine.findOne('sys_metadata', { + where: { type: singularType, name: request.name, organization_id: orgId ?? null, state: 'draft' }, + }); + draftPackageId = (draftRow as { package_id?: string | null } | null)?.package_id ?? null; + } const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ type: singularType, name: request.name, organizationId: orgId, body: draftForGate.body, + ...(draftPackageId ? { packageId: draftPackageId } : {}), }); if (intakeRefusal) throw intakeRefusal; } diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index ffbf21bbf76..78b866df24a 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -25,8 +25,9 @@ // - withdrawn in the organization, open env-wide: both doors refuse; // - open in both: both doors accept; // - a form only the organization has (no env-wide body): it is served; -// - not a withdrawal (the link cleared env-wide, or another view sharing the -// slug withdrawn): it is served. +// - not a withdrawal (the link cleared env-wide, a switch merely absent, or +// another view sharing the slug withdrawn): it is served; +// - the same view re-pointed at a new slug in the organization: still closed. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -138,15 +139,15 @@ function build(setup: Setup) { return { createData, getMetaItems, - async get() { + async get(slug = 'contact-us') { const res = mockRes(); - await resolve.handler({ params: { slug: 'contact-us' }, query: {}, headers: {} } as any, res); + await resolve.handler({ params: { slug }, query: {}, headers: {} } as any, res); return res; }, - async post() { + async post(slug = 'contact-us') { const res = mockRes(); await submit.handler( - { params: { slug: 'contact-us' }, query: {}, headers: {}, body: { name: 'x', email: 'x@example.com' } } as any, + { params: { slug }, query: {}, headers: {}, body: { name: 'x', email: 'x@example.com' } } as any, res, ); return res; @@ -293,6 +294,28 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect((await s.post()).statusCode).toBe(201); }); + it('only an explicit false withdraws: env-wide link + enabled true with allowAnonymous absent does not close the org\'s open form', async () => { + const absent = formView(true, { enabled: true, publicLink: '/forms/contact-us' }); + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [absent] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { + it(`the same view re-pointed at a new slug (${link}) in the organization stays closed`, async () => { + const slug = link.replace('/forms/', ''); + const s = build({ + envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(false)], + sharing: { enabled: true, allowAnonymous: true, publicLink: link }, + }); + const get = await s.get(slug); + expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); + const post = await s.post(slug); + expect([post.statusCode, post.body.code]).toEqual([404, 'FORM_NOT_FOUND']); + expect(s.createData).not.toHaveBeenCalled(); + }); + } + for (const tenancy of ['org', 'no-org'] as const) { it(`two different views sharing a slug in one read do not close each other (tenancy ${tenancy})`, async () => { // Another view (another app's form) names the same slug and withdraws From 95951e19bcabad2615e1fa3f87d3f4f369cdef1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:35:09 +0000 Subject: [PATCH 10/19] docs: the same-form identity rule for a public form withdrawal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/public-form-withdrawal-kill-switch.md | 9 +++++---- content/docs/ui/public-data-collection.mdx | 11 ++++++----- packages/metadata-core/src/anonymous-form-intake.ts | 8 ++++---- packages/rest/src/rest-server.ts | 7 ++++--- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index f4f4ac83a49..67881432416 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -8,8 +8,9 @@ A public form's explicit intake withdrawal at any metadata layer now holds: laye Clause-②: yes (widening) -- **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. It closes the same form only: the same view, at the same place in it (`form`, the same `formViews` entry, or its `config`), across its metadata layers. A different view that uses the same public slug is a different form, and the two never close each other. Removing the `sharing` block, clearing or changing the `publicLink`, or deleting the view at one layer is not a withdrawal. A sharing that names no public link withdraws nothing, whether it is a raw body or a schema-parsed one whose `enabled`/`allowAnonymous` defaults read `false`. -- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a form only when no layer they read withdraws it. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record, whatever another layer says. A form that is open at every layer is served as before. A form that only an organization carries is still served there. -- **Behaviour change.** Before this release, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That is reversed on purpose. The environment-wide withdrawal now wins, and the organization's copy cannot re-open the form. -- **Organization-scoped saves.** A `view` save in the organization the doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This holds even when the organization's copy was already open before the withdrawal, so re-saving that overlay is refused too. A container-shaped body (`formViews`, `form`) is judged the way the list read expands it. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. Rollback and commit-revert restores are not gated by this judgement yet. The doors still keep such a form closed. +- **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. Only an explicit `false` counts: a switch that is absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer is not a withdrawal either. A sharing that names no public link withdraws nothing. +- **What counts as the same form.** A withdrawal closes the same form across layers, and identity is the stored view definition (the row an overlay is keyed by). Inside that definition, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug. Either match is enough, so moving the form to another key or place, renaming it, or pointing its link at a new slug (including a change of letter case) does not escape. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. A different view that uses the same slug is a different form, and the two never close each other. +- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a form only when the env-wide layer beneath the organization's read does not withdraw the same view, matched by name and then by place or slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. +- **Behaviour change.** Between 17.6.0 and this fix, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That behaviour never shipped in a release, and it is reversed on purpose. The environment-wide withdrawal now wins. +- **Organization-scoped saves.** A `view` save or draft promotion in the organization the doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. The save is judged twice: once against the env-wide view list, the way the doors read it (a container-shaped body is expanded the way the list read expands it), and once against the env-wide body of the same stored row. This holds even when the organization's copy was already open before the withdrawal. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. Rollback and commit-revert restores are not gated by this judgement yet. - **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index b6fc25aea2b..e8362bb5d72 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -54,14 +54,15 @@ Set `sharingModel: 'private'` on the object so submissions are staff-scoped afte ### 4. Withdraw a public form -To stop taking submissions, keep the form's `publicLink` and clear a switch: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created. +To stop taking submissions, keep the form's `publicLink` and set a switch to `false`: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created. -A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again: the endpoints keep answering not found, and an organization-scoped save that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. +A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again. The endpoints keep answering not found, and an organization-scoped save that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. -Only an explicit withdrawal closes the form: +Only an explicit withdrawal of the same form closes it: -- **It closes the same form only.** A withdrawal applies to the view that carries it, at the same place in that view (`form`, the same `formViews` entry, or the view's own `config`), across its layers. A different view that uses the same public link (for example, another app's "contact us" form) is a separate form. It neither closes this one nor is closed by it. -- **Removing is not withdrawing.** Removing the `sharing` block, clearing or changing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. To close a form for good, keep the link and set `enabled: false` or `allowAnonymous: false`. +- **The same form is the same stored view definition.** An organization's copy of a view is keyed by the name of the definition it overrides. Inside that definition, a withdrawn form matches the organization's form by its place (`form`, the same `formViews` entry, or the view's own `config`) or by its public link. A match on either is enough. So renaming the `formViews` key, moving the form to another place, or pointing the link at a new slug (including a change of letter case) does not re-open it. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. +- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. +- **Only `false` withdraws.** A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. To close a form for good, keep the link and set `enabled: false` or `allowAnonymous: false`. ## Why diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index eb244c9ecbf..5c8c18428d3 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -22,10 +22,10 @@ * * Clearing either switch withdraws the form from every anonymous door. * - * A withdrawal is a kill switch: any metadata layer the doors read whose body - * of the same view explicitly withdraws the form (the link kept, a switch - * cleared) closes it, and layering may only narrow intake, never re-open it - * ({@link anonymousFormIntakeWithdrawnIn}). + * A withdrawal is a kill switch: any metadata layer whose body of the same + * stored row 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 diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3151ae9c0a7..3f84ea09ee1 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10719,9 +10719,10 @@ export class RestServer { // A withdrawal is a kill switch: layering may only narrow anonymous // intake, never re-open it. A candidate is served only when no layer // beneath the read it is found in explicitly withdraws the same form - // (`anonymousFormIntakeWithdrawnIn`: the same view, the same slot, the - // link kept with a switch cleared). Another view publishing the same - // slug is a different form and closes nothing. + // (`anonymousFormIntakeWithdrawnIn`: the same view name, matched by + // slot or by slug, the link kept with a switch set to `false`). + // Another view publishing the same slug is a different form and closes + // nothing. const findPublicFormView = ( views: any[], slug: string, From 41d5266826ba7524db117bcd2a1e603e9c9d370c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:00:30 +0000 Subject: [PATCH 11/19] test: pin a package's schema-parsed false as a withdrawal, and the env-wide definition as the switch A package artifact that keeps its public link and never switches `enabled` on carries an explicit `false` once parsed, and that is a withdrawal: an organization-scoped save that opens it is refused. An env-wide save may open a form the package ships closed, and the env-wide list then serves that open body. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/anonymous-form-intake.test.ts | 12 +++++ .../protocol.org-scoped-write-refused.test.ts | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index dfb4141fe3d..f8bab496190 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -234,6 +234,18 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same } }); + it('a schema-parsed `false` that keeps the link IS a withdrawal (a package artifact fails closed)', () => { + // A package artifact is served as parsed, and the schema defaults + // `enabled` to false: a shipped sharing that keeps its link and never + // switches `enabled` on carries an explicit `false` once parsed. That is + // a withdrawal. Its raw body, with the switch absent, is not one. + const raw = { allowAnonymous: true, publicLink: '/forms/contact-us' }; + const parsed = SharingConfigSchema.parse(raw); + expect(parsed.enabled).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false); + }); + it('the same slot with a new slug (case-only included) is the same form: closed', () => { const withdrawn = [view({ ...OPEN, enabled: false })]; for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { 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 79c7e86cb06..e929e7dbe17 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 @@ -75,6 +75,7 @@ import { afterEach, describe, expect, it } from 'vitest'; // would close a dependency cycle turbo rejects outright. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { SharingConfigSchema } from '@objectstack/spec/ui'; import { ObjectStackProtocolImplementation } from './protocol.js'; interface Row { @@ -847,4 +848,56 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se }); expect(on.success).toBe(true); }); + + // A package's shipped form is part of the env-wide definition, not a layer + // of its own beneath it. A schema-parsed `false` on the artifact (the schema + // defaults `enabled` to false) is an explicit withdrawal, so it fails closed; + // and the env-wide definition is the administrator's switch, so an env-wide + // save may open a form the package ships closed. + describe('single: a package-shipped form', () => { + const LINK = '/forms/walled-intake'; + // As the loader serves it: parsed, `enabled` never switched on. + const shipped = { + name: 'task.intake_form', label: 'Intake', object: 'task', viewKind: 'form', + config: { sharing: SharingConfigSchema.parse({ allowAnonymous: true, publicLink: LINK }) }, + _packageId: 'showcase', + }; + + function makePackageProtocol() { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'view' ? [shipped] : []); + engine.registry.getArtifactItem = (type: string, name: string) => + (type === 'view' && name === shipped.name ? shipped : undefined); + const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; + return { protocol, rows }; + } + + it('the parsed artifact carries an explicit `false` that keeps the link', () => { + expect(shipped.config.sharing).toMatchObject({ enabled: false, allowAnonymous: true, publicLink: LINK }); + }); + + it('a schema-parsed `false` is a withdrawal: an org-scoped save that opens it is refused', async () => { + const { protocol, rows } = makePackageProtocol(); + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('the env-wide definition is the switch: an env-wide save opens it, and the env-wide list serves that body', async () => { + const { protocol } = makePackageProtocol(); + expect((await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) })).success) + .toBe(true); + // The env-wide layer the anonymous doors read beneath an organization. + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + const named = (envWide.items as any[]).filter((v) => v?.name === 'task.intake_form'); + expect(named).toHaveLength(1); + expect(named[0].config.sharing).toMatchObject({ enabled: true, allowAnonymous: true, publicLink: LINK }); + // So an organization overlay that keeps it open is no longer refused. + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a', + })).success).toBe(true); + }); + }); }); From 79b847042df7ef332003ae9620cfcdd3cd75c181 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:01:44 +0000 Subject: [PATCH 12/19] docs: state the withdrawal rules by door, the package rules and the known limit The write door judges an organization-scoped save or publish by the stored row, so renamed keys, `form.name`, slot moves and expansion renames are the same form. The anonymous doors judge by the served item name, by slot or slug, and withdraw only on an explicit false. A package's parsed false is a withdrawal, and the env-wide definition may open a form the package ships closed. An overlay stored before the withdrawal, or restored by rollback or revert, that keeps the form open under another key or slot is a known limit. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../public-form-withdrawal-kill-switch.md | 7 ++++--- content/docs/ui/public-data-collection.mdx | 14 +++++++++---- .../src/anonymous-form-intake.ts | 21 ++++++++++++++----- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index 67881432416..97cd27fe439 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -9,8 +9,9 @@ A public form's explicit intake withdrawal at any metadata layer now holds: laye Clause-②: yes (widening) - **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. Only an explicit `false` counts: a switch that is absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer is not a withdrawal either. A sharing that names no public link withdraws nothing. -- **What counts as the same form.** A withdrawal closes the same form across layers, and identity is the stored view definition (the row an overlay is keyed by). Inside that definition, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug. Either match is enough, so moving the form to another key or place, renaming it, or pointing its link at a new slug (including a change of letter case) does not escape. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. A different view that uses the same slug is a different form, and the two never close each other. -- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a form only when the env-wide layer beneath the organization's read does not withdraw the same view, matched by name and then by place or slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. +- **Organization-scoped saves and publishes.** A `view` save or draft promotion in the organization the anonymous form doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This check judges by the stored row: the organization's body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package's artifact), and also with the env-wide view list the way the doors read it (a container-shaped body is expanded the way the list read expands it). Inside the row, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug, and either match is enough. So a renamed `formViews` key, a `form.name`, a move to another place, a listViews collision rename in the expansion, and a new or re-cased slug are all judged as the same form. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. The check also covers an organization copy that was already open before the withdrawal, the next time it is saved. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. +- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` judge by the name of the view item they serve. Beneath the organization's read they read the env-wide view list, and they serve a form only when the env-wide item of the same name does not explicitly withdraw a form in the same place or with the same slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. A different view that uses the same slug is a different form, and the two never close each other. +- **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. Its artifact is read as parsed, and the schema defaults `enabled` to `false`, so a parsed `false` on a form that keeps its link is an explicit withdrawal (fail closed). The env-wide definition is the administrator's switch: an env-wide save may open a form the package ships closed. +- **Known limit.** The doors match by served item name, and the save check runs only on an organization-scoped save or publish. An organization overlay that was stored before the env-wide withdrawal, or that a rollback or commit-revert restores, can still be served if it keeps the form open under a different key or place than the env-wide definition. Withdraw the form in that overlay to close it. Rollback and commit-revert restores are not gated by the save check. - **Behaviour change.** Between 17.6.0 and this fix, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That behaviour never shipped in a release, and it is reversed on purpose. The environment-wide withdrawal now wins. -- **Organization-scoped saves.** A `view` save or draft promotion in the organization the doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. The save is judged twice: once against the env-wide view list, the way the doors read it (a container-shaped body is expanded the way the list read expands it), and once against the env-wide body of the same stored row. This holds even when the organization's copy was already open before the withdrawal. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. Rollback and commit-revert restores are not gated by this judgement yet. - **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index e8362bb5d72..0e977efb1f0 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -56,13 +56,19 @@ Set `sharingModel: 'private'` on the object so submissions are staff-scoped afte To stop taking submissions, keep the form's `publicLink` and set a switch to `false`: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created. -A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again. The endpoints keep answering not found, and an organization-scoped save that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. +A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again: the endpoints keep answering not found, and an organization-scoped save or publish that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself. -Only an explicit withdrawal of the same form closes it: +**What counts as a withdrawal.** Only an explicit `false` withdraws, on a sharing that keeps its `publicLink`. A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. -- **The same form is the same stored view definition.** An organization's copy of a view is keyed by the name of the definition it overrides. Inside that definition, a withdrawn form matches the organization's form by its place (`form`, the same `formViews` entry, or the view's own `config`) or by its public link. A match on either is enough. So renaming the `formViews` key, moving the form to another place, or pointing the link at a new slug (including a change of letter case) does not re-open it. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. +**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently: + +- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. +- **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link. - **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. -- **Only `false` withdraws.** A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. To close a form for good, keep the link and set `enabled: false` or `allowAnonymous: false`. + +**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. The package's definition is read as parsed, and the schema defaults `enabled` to `false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. + +**Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. ## Why diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index 5c8c18428d3..f059a9a45bd 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -23,7 +23,7 @@ * Clearing either switch withdraws the form from every anonymous door. * * A withdrawal is a kill switch: any metadata layer whose body of the same - * stored row explicitly withdraws the form (the link kept, a switch set to + * 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}). * @@ -264,7 +264,11 @@ function anonymousFormSlot(view: Record, candidate: AnonymousFormIn * false` or `allowAnonymous === false`. Judged on the body as stored: a switch * that is absent is not a withdrawal (only an explicit `false` is), and a * sharing with no public link withdraws nothing — removing the sharing block or - * clearing the link is not a withdrawal. + * clearing the link is not a withdrawal. A body served as parsed (a package + * artifact) carries the schema's default `enabled: false`, which is an + * explicit `false`: a shipped form that keeps its link without switching + * `enabled` on is withdrawn (fail closed). The env-wide definition is the + * switch above it, so an env-wide save may still open it. */ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; slug: string }> { if (!view || typeof view !== 'object') return []; @@ -294,9 +298,16 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; * withdraws it, and the organization-scoped write door refuses a save that * would leave one open. * - * Identity is the stored row: `layer` holds bodies of rows, and the candidate's - * `view` is a body of a row of the same `name` — the row its overlay is keyed - * by. The layer withdraws the candidate when its body of that row EXPLICITLY + * Identity is the `name`: the layer withdraws the candidate only through a + * body of the same `name` as the candidate's `view`. What that name is depends + * on the caller. The organization-scoped write door passes the env-wide body + * of the stored row the overlay is keyed by, so a key rename, a `form.name`, + * a slot move or an expansion rename is still judged against the form it was. + * The anonymous doors pass the env-wide view list and the item they serve, so + * they judge by the served item name (a known limit: an overlay stored before + * the withdrawal, or restored by rollback or revert, that moves its form to + * another key or slot is not matched there). The layer withdraws the + * candidate when its body of that name EXPLICITLY * withdraws a form ({@link anonymousFormExplicitWithdrawals}) that matches the * candidate by slot (the same `form`, `formViews` key or `config`) OR by slug * (the same public link, compared exactly as the doors resolve it). Either From aa85cb7869e20a376a715ce9294d5656638dd402 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:30:40 +0000 Subject: [PATCH 13/19] fix(metadata-protocol): judge the draft a publish promotes, under its package A draft is keyed by its package too (ADR-0048), so two packages can each hold a draft of the same view in one organization. The publish gate now reads the draft under the same package key the promotion uses: the stated binding, or, when none is stated, the binding of the draft row resolved once and then stated to both the read and the promotion. The judged body is the body that becomes active. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../protocol.org-scoped-write-refused.test.ts | 48 +++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 53 +++++++++++++------ 2 files changed, 84 insertions(+), 17 deletions(-) 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 e929e7dbe17..a08cdeb9875 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 @@ -652,6 +652,54 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se expect(orgRows(rows).filter((r) => r.org === 'org_a' && r.state === 'active')).toEqual([]); }); + // ADR-0048 keys a draft by its package too: two packages can each hold a + // draft of the same view in one organization. The promotion judges the + // draft it promotes, under the same key, never the other package's. + describe('walled: two packages hold a draft of the same view in one organization', () => { + async function seedTwoPackageDrafts() { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + // Package A's draft leaves the anonymous intake alone; package B's + // withdraws it, which an organization the doors never read refuses. + await seedLegacyOrgDraft(protocol, { + type: 'view', name: 'task.intake_form', body: FORM_VIEW(true, 'Intake (A)'), + organizationId: 'org_a', packageId: 'pkg_a', + }); + await seedLegacyOrgDraft(protocol, { + type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), + organizationId: 'org_a', packageId: 'pkg_b', + }); + const draftsOf = () => Array.from(rows.values()) + .filter((r) => r.organization_id === 'org_a' && r.state === 'draft') + .map((r) => r.package_id) + .sort(); + const activeOf = () => Array.from(rows.values()) + .filter((r) => r.organization_id === 'org_a' && r.state === 'active') + .map((r) => r.package_id); + expect(draftsOf()).toEqual(['pkg_a', 'pkg_b']); + return { protocol, draftsOf, activeOf }; + } + + it('promoting package B judges B\'s draft: refused, and nothing becomes active', async () => { + const { protocol, draftsOf, activeOf } = await seedTwoPackageDrafts(); + await expect(protocol.publishMetaItem({ + type: 'view', name: 'task.intake_form', organizationId: 'org_a', packageId: 'pkg_b', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(activeOf()).toEqual([]); + expect(draftsOf()).toEqual(['pkg_a', 'pkg_b']); + }); + + it('control: promoting package A judges A\'s draft and promotes it, leaving B\'s draft pending', async () => { + const { protocol, draftsOf, activeOf } = await seedTwoPackageDrafts(); + const res = await protocol.publishMetaItem({ + type: 'view', name: 'task.intake_form', organizationId: 'org_a', packageId: 'pkg_a', + }); + expect(res.success).toBe(true); + expect(activeOf()).toEqual(['pkg_a']); + expect(draftsOf()).toEqual(['pkg_b']); + }); + }); + it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => { const { protocol, rows } = makeTenancyProtocol(null); await publishEnvWide(protocol); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f6384f84ab6..00561e891b8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16017,9 +16017,14 @@ export class ObjectStackProtocolImplementation implements // `name` is a container under the save name); a view item is the save // name's own row. const stamped = raw.name ? raw : { ...raw, name: args.name }; + // The package the row is saved under is part of its identity + // (ADR-0048), carried the way the list read serves it (`_packageId`), + // so another package's withdrawal of the same name closes nothing. + // An expansion's items carry theirs already. + const bound = args.packageId ? { _packageId: args.packageId } : {}; const served: unknown[] = isAggregatedViewContainer(stamped) ? this.expandRuntimeViewContainer(args.type, stamped, { packageId: args.packageId ?? undefined }) - : [{ ...raw, name: args.name }]; + : [{ ...raw, name: args.name, ...bound }]; const open = served.flatMap((view) => anonymousFormIntakeCandidates(view).map((c) => ({ view, c }))); if (open.length === 0) return null; const envWide: any = await this.getMetaItems({ type: args.type }); @@ -16034,7 +16039,7 @@ export class ObjectStackProtocolImplementation implements // against the form it was, by slot or by slug). const envRows = (await this.envWideRawViewRows(args.type, args.name)).map((r) => ({ ...r, name: args.name })); if (envRows.length > 0) { - const own = { ...raw, name: args.name }; + const own = { ...raw, name: args.name, ...bound }; for (const c of anonymousFormIntakeCandidates(own)) { if (anonymousFormIntakeWithdrawnIn(envRows, own, c)) closed.add(c.slug); } @@ -21432,9 +21437,26 @@ export class ObjectStackProtocolImplementation implements // Without this the gate would be trivially bypassable by anyone who // saves `?mode=draft` and then POSTs `/publish` — which is exactly what // Studio's designer surface does on every edit. + // + // The draft is read under ONE package key, and `repo.promoteDraft` + // below promotes under that same key, so the body judged here is the + // body that becomes active. ADR-0048 keys a draft by + // `(org, type, name, package_id)`: two packages can each hold a draft + // of the same name in one org, and a read without the package + // dimension picks either. The key is the caller's stated binding + // (spelled exactly as `repo.promoteDraft` receives it); with none + // stated, the binding of the draft row this promotion resolves, read + // once and then stated to both the read and the promotion. + let draftKey: string | null | undefined = 'packageId' in request ? (request.packageId ?? null) : undefined; + if (draftKey === undefined) { + const draftRow = await this.engine.findOne('sys_metadata', { + where: { type: singularType, name: request.name, organization_id: orgId, state: 'draft' }, + }); + if (draftRow) draftKey = (draftRow as { package_id?: string | null }).package_id ?? null; + } const draftForGate = await repo.get( { type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters[0], - { state: 'draft' }, + { state: 'draft', ...(draftKey !== undefined ? { packageId: draftKey } : {}) }, ); // [#21470] …and the divergent `name` refusal, on the same body and for // the same reason: a draft stored before `saveMetaItem` judged every @@ -21450,15 +21472,9 @@ export class ObjectStackProtocolImplementation implements // The promotion half of {@link anonymousFormIntakeOrgScopeRefusal}: a // draft saved before that refusal existed must not reach `active`. if (draftForGate) { - // The binding the promoted row is placed by: the request's, else the - // draft row's own (a container's expansion is placed by it). - let draftPackageId: string | null | undefined = request.packageId; - if (draftPackageId === undefined && singularType === 'view' && orgId) { - const draftRow = await this.engine.findOne('sys_metadata', { - where: { type: singularType, name: request.name, organization_id: orgId ?? null, state: 'draft' }, - }); - draftPackageId = (draftRow as { package_id?: string | null } | null)?.package_id ?? null; - } + // The binding the promoted row is placed by: the key the draft was + // read under above (a container's expansion is placed by it). + const draftPackageId = draftKey; const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({ type: singularType, name: request.name, @@ -21563,11 +21579,14 @@ export class ObjectStackProtocolImplementation implements // audit writer. This door's default says what happened without it. message: request.message || 'publish draft', intent, - // [#8907] Spread, not `packageId: request.packageId`: `null` is - // a meaningful scope (the unbound row) and `undefined` means - // "no package in hand", so the key must be ABSENT rather than - // present-and-undefined for the historical resolution to hold. - ...('packageId' in request ? { packageId: request.packageId ?? null } : {}), + // [#8907] Spread: `null` is a meaningful scope (the unbound + // row), so the key is ABSENT rather than present-and-undefined + // when there is none. The key the gated draft was read under (see `draftForGate`): + // the stated binding, or the resolved row's own when none was + // stated, so the promotion cannot pick a different package's + // draft than the one judged above. Absent only when no draft + // was found, where the promotion answers `NO_DRAFT` as before. + ...(draftKey !== undefined ? { packageId: draftKey } : {}), }); return { singularType, orgId, advisories: runtimeAdvisories, result }; } catch (err: any) { From f37e09c9565e5e086b5f913cd11ad4e971fe6e67 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:30:40 +0000 Subject: [PATCH 14/19] fix(metadata-core): a withdrawal closes only its own package's form of a name The env-wide view list serves one item per package for a name (ADR-0048), so the withdrawal judge compares a layer body only when it and the served view are bound to the same package, or either is bound to none (the package-less definition stands in for every package's row). The write door carries the package a row is saved under the same way. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/anonymous-form-intake.test.ts | 28 +++++++++++++++++++ .../src/anonymous-form-intake.ts | 16 ++++++++++- .../rest/src/public-form-withdrawal.test.ts | 25 +++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index f8bab496190..d45736e3fbc 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -217,6 +217,34 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same )).toBe(false); }); + describe('the package is part of the row (ADR-0048)', () => { + const bound = (body: Record, pkg: string) => ({ ...body, _packageId: pkg }); + const withdrawn = view({ ...OPEN, enabled: false }); + + it('another package\'s withdrawal of the same name closes nothing', () => { + const openA = bound(openView, 'pkg_a'); + const [c] = anonymousFormIntakeCandidates(openA); + expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openA, c)).toBe(false); + }); + + it('the same package\'s withdrawal of the same name closes it', () => { + const openA = bound(openView, 'pkg_a'); + const [c] = anonymousFormIntakeCandidates(openA); + expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_a')], openA, c)).toBe(true); + // Beside another package's open body of that name: still closed. + expect(anonymousFormIntakeWithdrawnIn([bound(openView, 'pkg_b'), bound(withdrawn, 'pkg_a')], openA, c)) + .toBe(true); + }); + + it('a body bound to no package stands in for every package\'s row of the name, on either side', () => { + const openA = bound(openView, 'pkg_a'); + const [c] = anonymousFormIntakeCandidates(openA); + expect(anonymousFormIntakeWithdrawnIn([withdrawn], openA, c)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openView, candidate)).toBe(true); + expect(anonymousFormIntakeWithdrawnIn([withdrawn], openView, candidate)).toBe(true); + }); + }); + it('not a withdrawal: no body of the row, no sharing, the link cleared', () => { expect(anonymousFormIntakeWithdrawnIn([openView], openView, candidate)).toBe(false); expect(anonymousFormIntakeWithdrawnIn([], openView, candidate)).toBe(false); diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index f059a9a45bd..228bd61aa21 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -291,6 +291,12 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; return out; } +/** The package a served `view` body is bound to (`_packageId`), or `undefined` when none. */ +function anonymousFormPackageOf(view: Record): string | undefined { + const p = view._packageId; + return typeof p === 'string' && p ? p : undefined; +} + /** * Does one metadata layer withdraw an open form candidate? A WITHDRAWAL IS A * KILL SWITCH: layering may only narrow anonymous intake, never re-open it, so @@ -317,7 +323,12 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; * the same container). * * Another row that publishes or withdraws the same slug is a different form - * and closes nothing. A layer with no body of the row, or whose body has no + * and closes nothing. That includes another package's row of the same name + * (ADR-0048 keys a row by its package too): when the layer body and the + * candidate's `view` are both bound to a package (`_packageId`) and the + * packages differ, the layer body closes nothing. A body bound to no package + * is the package-less definition, which stands in for every package's row of + * that name, so it is compared whatever the other side's package is. A layer with no body of the row, or whose body has no * explicit withdrawal, withdraws nothing, so a form published only in an * organization stays open there. */ @@ -331,9 +342,12 @@ export function anonymousFormIntakeWithdrawnIn( const name = typeof v.name === 'string' && v.name ? v.name : undefined; if (name === undefined) return false; const slot = anonymousFormSlot(v, candidate); + const pkg = anonymousFormPackageOf(v); for (const other of layer) { if (!other || typeof other !== 'object') continue; if ((other as Record).name !== name) continue; + const otherPkg = anonymousFormPackageOf(other as Record); + if (pkg !== undefined && otherPkg !== undefined && pkg !== otherPkg) continue; for (const w of anonymousFormExplicitWithdrawals(other)) { if (w.slot === slot || w.slug === candidate.slug) return true; } diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 78b866df24a..fe0b49f4153 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -98,6 +98,8 @@ interface Setup { envWideViews?: unknown[]; /** Extra views every read answers alongside the form view. */ extraViews?: unknown[]; + /** Replaces the organization read's whole view list (the env-wide read is unchanged). */ + orgViews?: unknown[]; } function build(setup: Setup) { @@ -105,6 +107,7 @@ function build(setup: Setup) { const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => { if (req.type === 'view') { if (req.organizationId !== ORG && setup.envWideViews) return setup.envWideViews; + if (req.organizationId === ORG && setup.orgViews) return setup.orgViews; const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; } @@ -270,6 +273,28 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect((await s.post()).statusCode).toBe(201); }); + // ADR-0048: the package is part of the row. The list reads serve one item + // per package for a name, each carrying its `_packageId`. + it('another package\'s withdrawal of the same view name does not close this package\'s form', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; + const s = build({ + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], + envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], + }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + it('the same package\'s withdrawal of the view name closes its form, beside another package\'s open one', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; + await expectClosed(build({ + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], + envWideViews: [{ ...formView(false), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], + })); + }); + it('another view withdrawing the same slug env-wide does not close this view\'s form', async () => { const other = { ...formView(false), name: 'legacy_contact' }; const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] }); From 6d6f894d8c647920ea5e6dc725ba4ac947f7cb6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:33:06 +0000 Subject: [PATCH 15/19] docs: state the package rule and that a parsed default applies to schema-parsed artifacts A package is part of a view row's identity, so one package's withdrawal closes only its own form of a name, and a package-less definition applies to every package's row of it. The schema's default `enabled: false` reaches an artifact only through the stack schema's parse; an artifact loaded without it is judged as written. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/public-form-withdrawal-kill-switch.md | 3 ++- content/docs/ui/public-data-collection.mdx | 4 ++-- packages/metadata-core/src/anonymous-form-intake.ts | 10 ++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index 97cd27fe439..3b5ee3c6022 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -11,7 +11,8 @@ Clause-②: yes (widening) - **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. Only an explicit `false` counts: a switch that is absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer is not a withdrawal either. A sharing that names no public link withdraws nothing. - **Organization-scoped saves and publishes.** A `view` save or draft promotion in the organization the anonymous form doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This check judges by the stored row: the organization's body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package's artifact), and also with the env-wide view list the way the doors read it (a container-shaped body is expanded the way the list read expands it). Inside the row, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug, and either match is enough. So a renamed `formViews` key, a `form.name`, a move to another place, a listViews collision rename in the expansion, and a new or re-cased slug are all judged as the same form. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. The check also covers an organization copy that was already open before the withdrawal, the next time it is saved. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. - **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` judge by the name of the view item they serve. Beneath the organization's read they read the env-wide view list, and they serve a form only when the env-wide item of the same name does not explicitly withdraw a form in the same place or with the same slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. A different view that uses the same slug is a different form, and the two never close each other. -- **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. Its artifact is read as parsed, and the schema defaults `enabled` to `false`, so a parsed `false` on a form that keeps its link is an explicit withdrawal (fail closed). The env-wide definition is the administrator's switch: an env-wide save may open a form the package ships closed. +- **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. A package artifact that was parsed by the stack schema (strict `defineStack`, the default) carries the schema's default `enabled: false`, so a shipped form that keeps its link without switching `enabled` on is an explicit withdrawal (fail closed). An artifact that reached the runtime without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: there a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch: an env-wide save may open a form the package ships closed. +- **Packages and names.** A package is part of a row's identity (ADR-0048): one package's withdrawal of a view name closes only that package's form of the name, never another package's. A definition bound to no package stands in for every package's row of its name, so its withdrawal applies to all of them. A publish judges the draft it promotes under the same package key: with two packages holding a draft of the same view in one organization, each draft is judged on its own publish. - **Known limit.** The doors match by served item name, and the save check runs only on an organization-scoped save or publish. An organization overlay that was stored before the env-wide withdrawal, or that a rollback or commit-revert restores, can still be served if it keeps the form open under a different key or place than the env-wide definition. Withdraw the form in that overlay to close it. Rollback and commit-revert restores are not gated by the save check. - **Behaviour change.** Between 17.6.0 and this fix, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That behaviour never shipped in a release, and it is reversed on purpose. The environment-wide withdrawal now wins. - **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index 0e977efb1f0..d3e81629817 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -64,9 +64,9 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de - **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. - **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link. -- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. +- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. The package is part of the view's identity too: when two packages each ship a view of the same name, one package's withdrawal closes only its own form. A definition bound to no package applies to every package's view of that name. -**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. The package's definition is read as parsed, and the schema defaults `enabled` to `false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. +**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. **Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index 228bd61aa21..c82212f5fa1 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -264,10 +264,12 @@ function anonymousFormSlot(view: Record, candidate: AnonymousFormIn * false` or `allowAnonymous === false`. Judged on the body as stored: a switch * that is absent is not a withdrawal (only an explicit `false` is), and a * sharing with no public link withdraws nothing — removing the sharing block or - * clearing the link is not a withdrawal. A body served as parsed (a package - * artifact) carries the schema's default `enabled: false`, which is an - * explicit `false`: a shipped form that keeps its link without switching - * `enabled` on is withdrawn (fail closed). The env-wide definition is the + * clearing the link is not a withdrawal. A package artifact parsed by the + * stack schema (strict `defineStack`) carries the schema's default + * `enabled: false`, which is an explicit `false`: a shipped form that keeps its + * link without switching `enabled` on is withdrawn (fail closed). An artifact + * that reached the runtime unparsed (`strict: false`, a hand-built manifest) + * is judged as written, so a switch it omits is absent. The env-wide definition is the * switch above it, so an env-wide save may still open it. */ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; slug: string }> { From d8657b5c19fd4fd2443953470adb1138f4b1f5df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:06:46 +0000 Subject: [PATCH 16/19] test(objectql): give the publish double's engine the draft-row read A publish that states no package now reads the pending draft row's package binding once, so the seed self-apply double's engine answers that read (no row) and the repository double answers the rest, as before. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../objectql/src/protocol-publish-package-drafts.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index ac8aa5c7e01..40c3b3187a6 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -452,7 +452,11 @@ describe('protocol.publishPackageDrafts (ADR-0033 / ADR-0067 D2)', () => { */ describe('protocol.publishMetaItem — seed self-apply', () => { function makePublishable(body: unknown) { - const protocol = new ObjectStackProtocolImplementation({} as never); + // A publish that states no package reads the pending draft row's package + // binding once (`engine.findOne` on `sys_metadata`) so the gate and the + // promotion resolve one draft (ADR-0048). No row here: the binding stays + // unstated and the repository double below answers both reads. + const protocol = new ObjectStackProtocolImplementation({ findOne: async () => null } as never); (protocol as any).ensureOverlayIndex = async () => {}; // [#21694] No lock on these items. Stubbed at `lockWriteRefusal`, the // verdict the publish path asks (`promoteDraftForPublish`, since #8594), From 4d5f6c4e61879de3a91b4870b9c70f6176512cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:12:35 +0000 Subject: [PATCH 17/19] fix(metadata-core): a withdrawal of a view name closes it in every package again The cross-package skip in the withdrawal judge is removed, with the package stamping in the org-scoped write door that only fed it. The write door's row anchor reads the env-wide row of a name without a package key, so the skip could leave it judging nothing when several packages ship the same view name. Without it, a withdrawal of a name closes that name's form in every package: it may over-close another package's form of the name, never under-close. The pins assert that, and that a row-anchored rename by a package-bound org save is refused when two packages ship the name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/anonymous-form-intake.test.ts | 10 ++-- .../src/anonymous-form-intake.ts | 19 ++----- .../protocol.org-scoped-write-refused.test.ts | 50 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 9 +--- .../rest/src/public-form-withdrawal.test.ts | 18 +++++-- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index d45736e3fbc..936e31b15c1 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -217,14 +217,18 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same )).toBe(false); }); - describe('the package is part of the row (ADR-0048)', () => { + // Known limit (fails closed): the package a body is bound to is not + // compared, so a withdrawal of a name closes that name in every package. + describe('the package is not compared: a withdrawal of a name closes it in every package', () => { const bound = (body: Record, pkg: string) => ({ ...body, _packageId: pkg }); const withdrawn = view({ ...OPEN, enabled: false }); - it('another package\'s withdrawal of the same name closes nothing', () => { + it('another package\'s withdrawal of the same name closes this package\'s form too', () => { const openA = bound(openView, 'pkg_a'); const [c] = anonymousFormIntakeCandidates(openA); - expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openA, c)).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openA, c)).toBe(true); + // Another package's OPEN body of the name withdraws nothing. + expect(anonymousFormIntakeWithdrawnIn([bound(openView, 'pkg_b')], openA, c)).toBe(false); }); it('the same package\'s withdrawal of the same name closes it', () => { diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index c82212f5fa1..4252000529a 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -293,12 +293,6 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; return out; } -/** The package a served `view` body is bound to (`_packageId`), or `undefined` when none. */ -function anonymousFormPackageOf(view: Record): string | undefined { - const p = view._packageId; - return typeof p === 'string' && p ? p : undefined; -} - /** * Does one metadata layer withdraw an open form candidate? A WITHDRAWAL IS A * KILL SWITCH: layering may only narrow anonymous intake, never re-open it, so @@ -325,12 +319,10 @@ function anonymousFormPackageOf(view: Record): string | undefin * the same container). * * Another row that publishes or withdraws the same slug is a different form - * and closes nothing. That includes another package's row of the same name - * (ADR-0048 keys a row by its package too): when the layer body and the - * candidate's `view` are both bound to a package (`_packageId`) and the - * packages differ, the layer body closes nothing. A body bound to no package - * is the package-less definition, which stands in for every package's row of - * that name, so it is compared whatever the other side's package is. A layer with no body of the row, or whose body has no + * and closes nothing. The package a body is bound to is NOT compared: a + * withdrawal of a name closes that name's form in every package (a known + * limit that fails closed: it may over-close another package's form of the + * same name, never under-close). A layer with no body of the row, or whose body has no * explicit withdrawal, withdraws nothing, so a form published only in an * organization stays open there. */ @@ -344,12 +336,9 @@ export function anonymousFormIntakeWithdrawnIn( const name = typeof v.name === 'string' && v.name ? v.name : undefined; if (name === undefined) return false; const slot = anonymousFormSlot(v, candidate); - const pkg = anonymousFormPackageOf(v); for (const other of layer) { if (!other || typeof other !== 'object') continue; if ((other as Record).name !== name) continue; - const otherPkg = anonymousFormPackageOf(other as Record); - if (pkg !== undefined && otherPkg !== undefined && pkg !== otherPkg) continue; for (const w of anonymousFormExplicitWithdrawals(other)) { if (w.slot === slot || w.slug === candidate.slug) return true; } 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 a08cdeb9875..c8758d8a511 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 @@ -897,6 +897,56 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se expect(on.success).toBe(true); }); + // Known limit (fails closed): a withdrawal of a view name closes that name + // in every package, so the row anchor judges an overlay against the + // env-wide row of its name whichever package that row came from. Two + // packages ship the container `task`; the env-wide row read for the name + // is package B's, which withdraws the form. + describe('single: two packages ship the same view name', () => { + const LINK = '/forms/walled-intake'; + const open = { enabled: true, allowAnonymous: true, publicLink: LINK }; + const shippedA = { + name: 'task', object: 'task', formViews: { intake_form: { sharing: open } }, _packageId: 'pkg_a', + }; + const shippedB = { + name: 'task', object: 'task', + formViews: { intake_form: { sharing: { ...open, allowAnonymous: false } } }, _packageId: 'pkg_b', + }; + + function makeTwoPackageProtocol() { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'view' ? [shippedA, shippedB] : []); + engine.registry.getArtifactItem = (type: string, name: string, pkg?: string) => { + if (type !== 'view' || name !== 'task') return undefined; + if (pkg === 'pkg_a') return shippedA; + return shippedB; + }; + const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; + return { protocol, rows }; + } + + it('a row-anchored rename by a package-bound org save is refused, and nothing is saved', async () => { + const { protocol, rows } = makeTwoPackageProtocol(); + const renamed = { name: 'task', object: 'task', formViews: { intake_v2: { sharing: open } } }; + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task', item: renamed, organizationId: 'org_a', packageId: 'pkg_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' }); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]); + }); + + it('control: the same package-bound org save that keeps the form withdrawn saves', async () => { + const { protocol } = makeTwoPackageProtocol(); + const kept = { + name: 'task', object: 'task', + formViews: { intake_v2: { sharing: { ...open, allowAnonymous: false } } }, + }; + expect((await protocol.saveMetaItem({ + type: 'view', name: 'task', item: kept, organizationId: 'org_a', packageId: 'pkg_a', + })).success).toBe(true); + }); + }); + // A package's shipped form is part of the env-wide definition, not a layer // of its own beneath it. A schema-parsed `false` on the artifact (the schema // defaults `enabled` to false) is an explicit withdrawal, so it fails closed; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 00561e891b8..2e2a8c31653 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16017,14 +16017,9 @@ export class ObjectStackProtocolImplementation implements // `name` is a container under the save name); a view item is the save // name's own row. const stamped = raw.name ? raw : { ...raw, name: args.name }; - // The package the row is saved under is part of its identity - // (ADR-0048), carried the way the list read serves it (`_packageId`), - // so another package's withdrawal of the same name closes nothing. - // An expansion's items carry theirs already. - const bound = args.packageId ? { _packageId: args.packageId } : {}; const served: unknown[] = isAggregatedViewContainer(stamped) ? this.expandRuntimeViewContainer(args.type, stamped, { packageId: args.packageId ?? undefined }) - : [{ ...raw, name: args.name, ...bound }]; + : [{ ...raw, name: args.name }]; const open = served.flatMap((view) => anonymousFormIntakeCandidates(view).map((c) => ({ view, c }))); if (open.length === 0) return null; const envWide: any = await this.getMetaItems({ type: args.type }); @@ -16039,7 +16034,7 @@ export class ObjectStackProtocolImplementation implements // against the form it was, by slot or by slug). const envRows = (await this.envWideRawViewRows(args.type, args.name)).map((r) => ({ ...r, name: args.name })); if (envRows.length > 0) { - const own = { ...raw, name: args.name, ...bound }; + const own = { ...raw, name: args.name }; for (const c of anonymousFormIntakeCandidates(own)) { if (anonymousFormIntakeWithdrawnIn(envRows, own, c)) closed.add(c.slug); } diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index fe0b49f4153..30078c18000 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -273,14 +273,24 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect((await s.post()).statusCode).toBe(201); }); - // ADR-0048: the package is part of the row. The list reads serve one item - // per package for a name, each carrying its `_packageId`. - it('another package\'s withdrawal of the same view name does not close this package\'s form', async () => { + // The list reads serve one item per package for a name, each carrying its + // `_packageId`. Known limit (fails closed): the package is not compared, so + // a withdrawal of a view name closes that name in every package. + it('another package\'s withdrawal of the same view name closes this package\'s form too', async () => { const openA = { ...formView(true), _packageId: 'pkg_a' }; - const s = build({ + await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', orgViews: [openA], envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], + })); + }); + + it('control: every package\'s body of the view name open serves the form', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; + const s = build({ + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], + envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); From af60aff7cab185bc9a9aa756f707bf85d81f8c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:27:11 +0000 Subject: [PATCH 18/19] docs: a withdrawal of a view name closes it in every package (known limit) The package rule is replaced by the known limit: a withdrawal of a view name closes that name's form in every package, which may over-close but never under-closes. Per-package precision is tracked separately. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/public-form-withdrawal-kill-switch.md | 2 +- content/docs/ui/public-data-collection.mdx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md index 3b5ee3c6022..b9fe5f02836 100644 --- a/.changeset/public-form-withdrawal-kill-switch.md +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -12,7 +12,7 @@ Clause-②: yes (widening) - **Organization-scoped saves and publishes.** A `view` save or draft promotion in the organization the anonymous form doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This check judges by the stored row: the organization's body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package's artifact), and also with the env-wide view list the way the doors read it (a container-shaped body is expanded the way the list read expands it). Inside the row, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug, and either match is enough. So a renamed `formViews` key, a `form.name`, a move to another place, a listViews collision rename in the expansion, and a new or re-cased slug are all judged as the same form. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. The check also covers an organization copy that was already open before the withdrawal, the next time it is saved. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. - **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` judge by the name of the view item they serve. Beneath the organization's read they read the env-wide view list, and they serve a form only when the env-wide item of the same name does not explicitly withdraw a form in the same place or with the same slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. A different view that uses the same slug is a different form, and the two never close each other. - **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. A package artifact that was parsed by the stack schema (strict `defineStack`, the default) carries the schema's default `enabled: false`, so a shipped form that keeps its link without switching `enabled` on is an explicit withdrawal (fail closed). An artifact that reached the runtime without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: there a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch: an env-wide save may open a form the package ships closed. -- **Packages and names.** A package is part of a row's identity (ADR-0048): one package's withdrawal of a view name closes only that package's form of the name, never another package's. A definition bound to no package stands in for every package's row of its name, so its withdrawal applies to all of them. A publish judges the draft it promotes under the same package key: with two packages holding a draft of the same view in one organization, each draft is judged on its own publish. +- **Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages ship a view of the same name, one package's withdrawal also closes the other package's form of that name: it may over-close, never under-close. Per-package precision is tracked in #21934. A publish judges the draft it promotes under the same package key: with two packages holding a draft of the same view in one organization, each draft is judged on its own publish. - **Known limit.** The doors match by served item name, and the save check runs only on an organization-scoped save or publish. An organization overlay that was stored before the env-wide withdrawal, or that a rollback or commit-revert restores, can still be served if it keeps the form open under a different key or place than the env-wide definition. Withdraw the form in that overlay to close it. Rollback and commit-revert restores are not gated by the save check. - **Behaviour change.** Between 17.6.0 and this fix, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That behaviour never shipped in a release, and it is reversed on purpose. The environment-wide withdrawal now wins. - **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index d3e81629817..206108708ef 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -64,10 +64,12 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de - **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view. - **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link. -- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. The package is part of the view's identity too: when two packages each ship a view of the same name, one package's withdrawal closes only its own form. A definition bound to no package applies to every package's view of that name. +- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it. **Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed. +**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name. This may close more than was meant, but it never leaves a withdrawn form open. Per-package precision is tracked in #21934. + **Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused. ## Why From 7882eef683e0d415c065f50049f705280a5771d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:08:30 +0000 Subject: [PATCH 19/19] test(dogfood): port the per-file cwd setup fix so the dispatch-gates self-test reads its mkdtemp base Main is red on the dispatch-gates self-test: the dogfood per-file cwd setup takes its mkdtempSync base from a value the scratch-dir scan cannot read. These three files are ported unchanged from the open fix branch (refs/pull/21935/head at 2edc5d59d4) so this branch's gates read green; they merge away when that fix lands on main. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../dogfood/test/per-file-cwd.global-setup.ts | 52 +++++++++++++------ .../qa/dogfood/test/per-file-cwd.setup.ts | 29 ++++++++--- packages/qa/dogfood/vitest.config.ts | 2 +- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts index eaa656093d0..284a6f6c9ec 100644 --- a/packages/qa/dogfood/test/per-file-cwd.global-setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.global-setup.ts @@ -10,22 +10,34 @@ // left by a developer's earlier run on a tree without this isolation, or by // a crashed run. The guard judges only what THIS run leaves, so an old // leftover never reds a run that wrote nothing. -// 2. It creates ONE temporary root for the run and hands it to every worker -// through `provide` / `inject`. Each test file makes its own working -// directory under that root. +// 2. It reserves a TAG for the run, `os-dogfood-run-XXXXXX`, as a directory +// `mkdtempSync` creates under the system temp directory, and hands the tag +// (a name, never a path) to every worker through `provide` / `inject`. +// Each test file makes its own working directory directly under the system +// temp directory, named `-file-XXXXXX`. // -// At the END of the run it removes that root, and with it every per-file -// directory. The removal is run-level, not per-file: on the `shared-showcase` -// project (`isolate: false`) one memoized boot serves every file on a worker, -// and its SQLite handles stay open in the directory of the file that booted it. +// At the END of the run it removes every directory whose name starts with this +// run's `-file-`, then the reservation itself. Another run's directories +// carry another tag, so a concurrent run on the same machine is never touched. +// The removal is run-level, not per-file: on the `shared-showcase` project +// (`isolate: false`) one memoized boot serves every file on a worker, and its +// SQLite handles stay open in the directory of the file that booted it. +// +// Why a tag and not a shared parent path (#21924): every `mkdtempSync` base in +// this tree must be one the tree's scratch-directory scan can read, so that an +// in-tree fixture root can never hide behind an expression +// (`scripts/pm/dispatch-gates.mjs`, "no mkdtempSync site in this tree takes a +// base the scan cannot read"). A path handed over through `inject()` is such an +// expression. `join(tmpdir(), ...)` is not: it is outside the tree by +// construction, whatever name follows it. // // ⛔ This teardown never JUDGES anything. On vitest 4.1.11 an error thrown from // a globalSetup teardown is printed as `error during close` and the run still // exits 0 (measured), so a guard placed here would be a false green. The guard // is a throwing `afterAll` in the per-file module, which fails a test file. -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { TestProject } from 'vitest/node'; @@ -34,19 +46,29 @@ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); declare module 'vitest' { export interface ProvidedContext { - /** The run's temporary root; each test file makes its working directory under it. */ - dogfoodCwdRoot: string; + /** The run's tag; each test file makes its working directory as `join(tmpdir(), '-file-')`. */ + dogfoodRunTag: string; } } -let runRoot: string | undefined; +/** The prefix of every per-file directory a run tagged `tag` creates under the system temp directory. */ +export function perFileDirPrefix(tag: string): string { + return `${tag}-file-`; +} + +let reservation: string | undefined; export function setup(project: TestProject): void { rmSync(join(PACKAGE_ROOT, '.objectstack'), { recursive: true, force: true }); - runRoot = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); - project.provide('dogfoodCwdRoot', runRoot); + reservation = mkdtempSync(join(tmpdir(), 'os-dogfood-run-')); + project.provide('dogfoodRunTag', basename(reservation)); } export function teardown(): void { - if (runRoot) rmSync(runRoot, { recursive: true, force: true }); + if (!reservation) return; + const prefix = perFileDirPrefix(basename(reservation)); + for (const name of readdirSync(tmpdir())) { + if (name.startsWith(prefix)) rmSync(join(tmpdir(), name), { recursive: true, force: true }); + } + rmSync(reservation, { recursive: true, force: true }); } diff --git a/packages/qa/dogfood/test/per-file-cwd.setup.ts b/packages/qa/dogfood/test/per-file-cwd.setup.ts index 0991eb9c55f..dc1d3de3d4b 100644 --- a/packages/qa/dogfood/test/per-file-cwd.setup.ts +++ b/packages/qa/dogfood/test/per-file-cwd.setup.ts @@ -19,10 +19,18 @@ // ## What it does // // At module top level, which runs before the test file's own imports, it makes -// a directory under the run's temporary root and `chdir`s into it. `afterAll` -// restores the previous working directory. The directories are removed at the -// end of the run by the globalSetup, not here: the memoized `shared-showcase` -// boot keeps its SQLite handles open in the first file's directory. +// a directory directly under the system temp directory, named with the run's +// tag (`-file-XXXXXX`), and `chdir`s into it. `afterAll` restores the +// previous working directory. The directories are removed at the end of the +// run by the globalSetup, which sweeps its own tag, not here: the memoized +// `shared-showcase` boot keeps its SQLite handles open in the first file's +// directory. +// +// The base is spelled `join(tmpdir(), ...)` on purpose (#21924): the tree's +// scratch-directory scan must be able to read every `mkdtempSync` base, and a +// path received through `inject()` is one it cannot read. Only the run's TAG +// comes through `inject()`, as a name component, and it is refused below if +// it could carry a separator. // // The invariant for every dogfood author: a file runs in its own temporary // cwd, so anything cwd-relative it writes is its own and disappears with the @@ -38,26 +46,31 @@ // what this run leaves. import { afterAll, inject } from 'vitest'; import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { perFileDirPrefix } from './per-file-cwd.global-setup.js'; /** `packages/qa/dogfood`, resolved from this module's own location. */ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); /** What a file must never leave in the package directory. */ const LEFTOVER = join(PACKAGE_ROOT, '.objectstack', 'data'); -const runRoot = inject('dogfoodCwdRoot'); -if (!runRoot) { +const runTag = inject('dogfoodRunTag'); +if (!runTag) { throw new Error( - 'per-file-cwd.setup.ts: no run root was provided. The globalSetup ' + + 'per-file-cwd.setup.ts: no run tag was provided. The globalSetup ' + '`test/per-file-cwd.global-setup.ts` must be wired in packages/qa/dogfood/vitest.config.ts; ' + 'without it this file would run in the package directory.', ); } +if (/[\\/]|\.\./.test(runTag)) { + throw new Error(`per-file-cwd.setup.ts: the run tag ${JSON.stringify(runTag)} is not a plain directory name.`); +} const previousCwd = process.cwd(); const presentAtStart = existsSync(LEFTOVER); -process.chdir(mkdtempSync(join(runRoot, 'file-'))); +process.chdir(mkdtempSync(join(tmpdir(), perFileDirPrefix(runTag)))); afterAll(() => { process.chdir(previousCwd); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index bbef0be21e5..841365bfeba 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -143,7 +143,7 @@ runProjectCliOverridePreflight({ // `.objectstack/data` exists in the package directory: that throw is the guard. // - The `globalSetup` below is ROOT-level: one run, one call, covering both // projects and each `OS_TEST_SHARD` slice (measured). It clears a stale -// `.objectstack` at the start and removes the run's temporary root at the end. +// `.objectstack` at the start and removes the run's per-file directories at the end. // Its teardown judges nothing, because a throw there exits 0 on vitest 4.1.11. // Both modules' headers carry the rest, including what a dogfood author owes. const PER_FILE_CWD = './test/per-file-cwd.setup.ts';