diff --git a/.changeset/public-form-withdrawal-kill-switch.md b/.changeset/public-form-withdrawal-kill-switch.md new file mode 100644 index 0000000000..b9fe5f0283 --- /dev/null +++ b/.changeset/public-form-withdrawal-kill-switch.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': patch +'@objectstack/metadata-protocol': patch +'@objectstack/metadata-core': minor +--- + +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) + +- **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. 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. +- **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 9700c47e95..206108708e 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -52,6 +52,26 @@ 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 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 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. + +**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. + +**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. + +**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 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. diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index ed6e3c81dc..936e31b15c 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -10,6 +10,7 @@ import { anonymousFormIntakeUnavailability, anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, + anonymousFormIntakeWithdrawnIn, anonymousFormObjectName, anonymousFormSharingPath, publicFormSlug, @@ -184,3 +185,133 @@ describe('where the reason is located, and the reason itself', () => { expect(message.endsWith(` ${anonymousFormIntakeUnavailableRemedy(u)}`)).toBe(true); }); }); + +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 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); + }); + + 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(false); + }); + + it('two different rows sharing a slug do not close each other', () => { + const other = view({ ...OPEN, enabled: false }, 'legacy_contact'); + expect(anonymousFormIntakeWithdrawnIn([openView, other], openView, candidate)).toBe(false); + const [otherOpen] = anonymousFormIntakeCandidates(view(OPEN, 'legacy_contact')); + expect(anonymousFormIntakeWithdrawnIn( + [view({ ...OPEN, enabled: false }), view(OPEN, 'legacy_contact')], view(OPEN, 'legacy_contact'), otherOpen, + )).toBe(false); + }); + + // 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 this package\'s form too', () => { + const openA = bound(openView, 'pkg_a'); + const [c] = anonymousFormIntakeCandidates(openA); + 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', () => { + 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); + 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: '' })], 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); + expect(parsed.enabled === true && parsed.allowAnonymous === true).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(false); + expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false); + } + }); + + 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']) { + 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([]); + }); + }); +}); diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index c347eb75d5..4252000529 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -22,6 +22,11 @@ * * Clearing either switch withdraws the form from every anonymous door. * + * A withdrawal is a kill switch: any metadata layer whose body of the same + * view name explicitly withdraws the form (the link kept, a switch set to + * `false`), matched by slot or by slug, closes it, and layering may only narrow + * intake, never re-open it ({@link anonymousFormIntakeWithdrawnIn}). + * * The candidates are the three shapes a view carries a form in: the nested * `form`, every `formViews` entry, and the flattened `config` of a * `viewKind: 'form'` item. @@ -240,3 +245,103 @@ 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`. + */ +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 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. 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 }> { + 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 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. + * + * 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 + * 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). + * + * Another row that publishes or withdraws the same slug is a different form + * 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. + */ +export function anonymousFormIntakeWithdrawnIn( + layer: ReadonlyArray, + view: unknown, + candidate: AnonymousFormIntakeCandidate, +): boolean { + if (!view || typeof view !== 'object') return false; + const v = view as Record; + 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 ((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 0c22dd43ca..c8758d8a51 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 { @@ -199,6 +200,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) => @@ -649,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); @@ -683,4 +734,268 @@ 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 keep public form '\/forms\/walled-intake' open/); + 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('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); + }); + + 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); + 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); + 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); + }); + + // 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; + // 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); + }); + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0b35ebb276..2e2a8c3165 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -103,6 +103,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()`). @@ -15936,6 +15940,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; @@ -15945,7 +15951,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, @@ -15971,6 +15979,115 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * An organization-scoped `view` write, in the organization the anonymous + * 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: 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 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' || 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 closed = new Set( + open.filter(({ view, c }) => anonymousFormIntakeWithdrawnIn(layer, view, c)).map(({ c }) => c.slug), + ); + // 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( + `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 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 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'; + 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)`? * @@ -19325,6 +19442,7 @@ export class ObjectStackProtocolImplementation implements name: request.name, organizationId: request.organizationId, body: request.item, + ...(request.packageId ? { packageId: request.packageId } : {}), }); if (intakeRefusal) throw intakeRefusal; } @@ -21314,9 +21432,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 @@ -21332,11 +21467,15 @@ 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 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, organizationId: orgId, body: draftForGate.body, + ...(draftPackageId ? { packageId: draftPackageId } : {}), }); if (intakeRefusal) throw intakeRefusal; } @@ -21435,11 +21574,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) { diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index ac8aa5c7e0..40c3b3187a 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), 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 eaa656093d..284a6f6c9e 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 0991eb9c55..dc1d3de3d4 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/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 0000000000..449ea548bf --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -0,0 +1,141 @@ +// 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 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 +// 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 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. + +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 leave it open is refused, and the doors stay closed', async () => { + await scope(organizationId); + // 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); + 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); + }); +}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index bbef0be21e..841365bfeb 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'; diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index 8ab4cb88b4..d55273f399 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 13817605e6..30078c1800 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -16,6 +16,18 @@ // - 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: 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; +// - 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'; @@ -82,14 +94,22 @@ 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[]; + /** Replaces the organization read's whole view list (the env-wide read is unchanged). */ + orgViews?: 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; + 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)]; + return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -122,15 +142,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; @@ -159,15 +179,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 +247,119 @@ 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('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); + }); + + // 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' }; + 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); + }); + + 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] }); + 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 () => { + 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('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 + // 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 e350395700..b0076412c6 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -78,6 +78,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, @@ -10723,11 +10724,24 @@ 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 + // beneath the read it is found in explicitly withdraws the same form + // (`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, + 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 }; @@ -10781,13 +10795,28 @@ 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 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', + ...(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