Skip to content

Commit ce53218

Browse files
fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour (#21473)
Fixes #21468 Clause-②: no Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring when the save is organization-scoped and the anonymous form doors cannot honour it; pinned both sides. Env-wide saves still withdraw and republish the form on every anonymous door, and single-posture deployments are unchanged. **Base:** this branch was cut from the #21331 fix branch while #21420 was queued; #21420 has since merged and `origin/main` is merged into this branch (`296d7342`), so the diff against `main` is exactly the five files below. ## What changed - `packages/metadata-protocol/src/protocol.ts`: a new refusal on the metadata save door, `anonymousFormIntakeOrgScopeRefusal`. It runs in `saveMetaItem` (active and draft saves) and in the draft-to-active promotion. It applies to an organization-scoped `view` write on a posture where the anonymous doors cannot honour it (read from the `tenancy` service), and only when the write changes which slugs the form opens to anonymous intake, compared with the env-wide definition. It answers `403 NOT_OVERRIDABLE` with `organizationId` and `docs` set. The message says why and gives the remedy: save it env-wide. This reuses the code and status of the sibling org-scope refusal (`orgScopedWriteRefusal`). There is no new error code and no spec change. - `packages/metadata-protocol/src/anonymous-form-intake.ts`: `anonymousFormIntakeSlugs`, which projects a view body to the slugs it opens to anonymous intake. It reads the same three candidate shapes and the same `allowAnonymous` + `publicLink` predicate as the anonymous doors. - Edits to a form that is organization-scoped but leaves its anonymous slug set alone still save. So do env-wide saves, and every save on a single posture, where `defaultOrgId()` is the admin's organization. ## Tests Run at `b5e4b515` (this branch's head when this was written). - `packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts`: a new describe block of 7 cases on the existing stub engine. The walled posture is modelled with `defaultOrgId` answering null. Four refusal cases: org-scoped withdrawal (envelope, and no org row written), org-scoped draft, org-scoped publish of an env-wide-private form, and promotion of a legacy org-scoped draft. Three controls: an org-scoped edit that leaves the sharing alone, the env-wide withdrawal, and the single posture (`defaultOrgId` equal to the org). Result: 22/22 pass. - `packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts`: a real boot with `multiTenant: 'posture-only'` (`isolated`, `defaultOrgId()` answers null) of a fixture form bound to an object declared `tenancy: { enabled: false }`. Precondition: the published form serves and accepts (200 / 201, the row lands). In an organization, the withdrawal answers 403 `NOT_OVERRIDABLE` and names the env-wide save and nothing is saved. An org-scoped label edit saves (200, `org=`). An env-wide withdrawal makes both doors answer `404 FORM_NOT_FOUND`, and no row lands. An env-wide republish restores both doors. Result: 5/5 pass. - The single-posture pins of #21420 (`showcase-public-form-withdrawal.dogfood.test.ts`) and `showcase-public-form.dogfood.test.ts` / `public-form-read-back-masking.dogfood.test.ts` are still green. - `pnpm --filter @objectstack/metadata-protocol test`: 201 files passed, 3 skipped; 2990 tests passed, 19 skipped. - `typecheck` passes for `@objectstack/metadata-protocol` and `@objectstack/dogfood`. - Ablation, run on the committed head: the guard's first line was mutated so that it always returns null, using `scripts/ablation-replace.mjs` with a restore trap. Then a rebuild, and `ablation-dist-preflight` confirmed the marker in 2 dist files. Unit: 4 red / 18 green (the four refusal cases). Dogfood: 1 red / 4 green (the organization-scoped refusal case). Restore: blob equals HEAD, `git diff HEAD` is empty, rebuilt, marker absent from all 24 dist files. - Derived gate families (`dispatch-gates.mjs`, 70): 68 exit 0. `check-plugin-teardown-shape --self-test` first exited 3 (shallow clone, pinned fixture unreachable); after fetching that commit it exited 0. `check:dual-build-cjs-loads` is NOT MEASURED (exit 3, `PREREQUISITE NOT MET`: unrelated packages have no `dist/`), declared to CI. `pnpm lint` was not run locally and is declared to CI. ## Acceptance notes - With no `tenancy` service registered, the refusal does not apply. Such a composition has no posture to judge and no session to carry an organization over HTTP. - On a walled posture, an anonymous submit into a form bound to a **tenant-scoped** object answers 500 (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` from the engine). That is why the walled pin uses a tenancy-disabled fixture object instead of the showcase contact form. This PR does not change it; it is reported to the dispatching seat. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f837a5 commit ce53218

5 files changed

Lines changed: 443 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring, with `403 NOT_OVERRIDABLE`, when the save is organization-scoped and the anonymous form doors cannot honour it. The message names the remedy: save the change env-wide, which every anonymous door honours. Drafts and draft promotion are refused alike. Other organization-scoped edits, env-wide saves and single-posture deployments are unchanged.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which public-form slugs a `view` body opens to anonymous intake.
5+
*
6+
* The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`,
7+
* `registerFormEndpoints` in `@objectstack/rest`) serve a form when one of a
8+
* view's form candidates carries `sharing.allowAnonymous === true` and a
9+
* `sharing.publicLink` that names the slug. The candidates are the same three
10+
* shapes those doors scan: the nested `form`, every `formViews` entry, and the
11+
* flattened `config` of a `viewKind: 'form'` item.
12+
*
13+
* Returns the sorted, de-duplicated slug set, normalised the way the doors
14+
* compare it (`/forms/x`, `forms/x` and `x` are one slug). Two bodies with the
15+
* same set open exactly the same anonymous doors.
16+
*/
17+
export function anonymousFormIntakeSlugs(view: unknown): string[] {
18+
if (!view || typeof view !== 'object') return [];
19+
const v = view as Record<string, any>;
20+
const sharings: unknown[] = [];
21+
if (v.form && typeof v.form === 'object') sharings.push(v.form.sharing);
22+
if (v.formViews && typeof v.formViews === 'object') {
23+
for (const fv of Object.values(v.formViews)) {
24+
if (fv && typeof fv === 'object') sharings.push((fv as any).sharing);
25+
}
26+
}
27+
if (v.viewKind === 'form' && v.config && typeof v.config === 'object') sharings.push(v.config.sharing);
28+
const slugs = new Set<string>();
29+
for (const s of sharings) {
30+
if (!s || typeof s !== 'object') continue;
31+
const sharing = s as Record<string, unknown>;
32+
if (sharing.allowAnonymous !== true) continue;
33+
if (typeof sharing.publicLink !== 'string' || !sharing.publicLink) continue;
34+
slugs.add(sharing.publicLink.replace(/^\/+/, '').replace(/^forms\//, ''));
35+
}
36+
return [...slugs].sort();
37+
}

‎packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,114 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused',
561561
expect(affected).toHaveLength(17);
562562
});
563563
});
564+
565+
/**
566+
* An org-scoped change to which public forms accept anonymous intake is
567+
* refused when the anonymous form doors would never read that organization:
568+
* they resolve the form in `tenancy.defaultOrgId()`'s organization, which a
569+
* walled posture (degraded or not) answers `null`. Same stub engine as above;
570+
* the `tenancy` service is the only addition.
571+
*/
572+
describe('org-scoped anonymous form intake changes the anonymous doors cannot see', () => {
573+
const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: '/forms/walled-intake' });
574+
const FORM_VIEW = (allowAnonymous: boolean, label = 'Intake') => ({
575+
name: 'task.intake_form',
576+
label,
577+
object: 'task',
578+
viewKind: 'form',
579+
config: { sharing: sharing(allowAnonymous) },
580+
});
581+
582+
/** `defaultOrgId` answers what the anonymous doors resolve. */
583+
function makeTenancyProtocol(defaultOrgId: string | null) {
584+
const { engine, rows } = makeStubEngine();
585+
const services = new Map<string, unknown>([['tenancy', { defaultOrgId: async () => defaultOrgId }]]);
586+
const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any;
587+
return { protocol, rows };
588+
}
589+
590+
async function publishEnvWide(protocol: any) {
591+
const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) });
592+
expect(res.success).toBe(true);
593+
}
594+
595+
it('walled (no organization for an anonymous request): the org-scoped withdrawal is refused and nothing is saved', async () => {
596+
const { protocol, rows } = makeTenancyProtocol(null);
597+
await publishEnvWide(protocol);
598+
599+
const refusal = protocol.saveMetaItem({
600+
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a',
601+
});
602+
await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
603+
await expect(refusal).rejects.toThrow(/Save it env-wide instead/);
604+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
605+
});
606+
607+
it('walled: an org-scoped draft of the withdrawal is refused too', async () => {
608+
const { protocol, rows } = makeTenancyProtocol(null);
609+
await publishEnvWide(protocol);
610+
611+
await expect(protocol.saveMetaItem({
612+
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', mode: 'draft',
613+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
614+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
615+
});
616+
617+
it('walled: an org-scoped publish of a form the env-wide definition keeps private is refused', async () => {
618+
const { protocol } = makeTenancyProtocol(null);
619+
const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) });
620+
expect(res.success).toBe(true);
621+
622+
await expect(protocol.saveMetaItem({
623+
type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a',
624+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
625+
});
626+
627+
it('walled: a legacy org-scoped draft of the withdrawal cannot be promoted', async () => {
628+
const { protocol, rows } = makeTenancyProtocol(null);
629+
await publishEnvWide(protocol);
630+
await seedLegacyOrgDraft(protocol, {
631+
type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), organizationId: 'org_a',
632+
});
633+
634+
await expect(
635+
protocol.publishMetaItem({ type: 'view', name: 'task.intake_form', organizationId: 'org_a' }),
636+
).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
637+
expect(orgRows(rows).filter((r) => r.org === 'org_a' && r.state === 'active')).toEqual([]);
638+
});
639+
640+
it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => {
641+
const { protocol, rows } = makeTenancyProtocol(null);
642+
await publishEnvWide(protocol);
643+
644+
const res = await protocol.saveMetaItem({
645+
type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (tenant)'), organizationId: 'org_a',
646+
});
647+
expect(res.success).toBe(true);
648+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([
649+
{ type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' },
650+
]);
651+
});
652+
653+
it('control (walled): the env-wide withdrawal is accepted', async () => {
654+
const { protocol } = makeTenancyProtocol(null);
655+
await publishEnvWide(protocol);
656+
657+
const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) });
658+
expect(res.success).toBe(true);
659+
expect(res.message).toContain('env-wide');
660+
});
661+
662+
it('control (single): the doors resolve this organization, so the org-scoped withdrawal is accepted', async () => {
663+
const { protocol, rows } = makeTenancyProtocol('org_a');
664+
await publishEnvWide(protocol);
665+
666+
const res = await protocol.saveMetaItem({
667+
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a',
668+
});
669+
expect(res.success).toBe(true);
670+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([
671+
{ type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' },
672+
]);
673+
});
674+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
// [#7560] ADR-0070's read-only-package rule, shared with the `/packages`
3939
// lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`.
4040
import { isWritablePackage as isWritablePackageShared } from './package-writability.js';
41+
import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js';
4142
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
4243
// [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the
4344
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
@@ -14946,6 +14947,66 @@ export class ObjectStackProtocolImplementation implements
1494614947
return err;
1494714948
}
1494814949

14950+
/**
14951+
* An organization-scoped `view` write that changes which public forms
14952+
* accept anonymous intake, on a deployment whose anonymous form doors do
14953+
* not read that organization. Returns the refusal, or `null` when the
14954+
* write is fine.
14955+
*
14956+
* An anonymous form request carries no session and so no organization.
14957+
* The doors resolve the form in `tenancy.defaultOrgId()`'s organization
14958+
* (`registerFormEndpoints` in `@objectstack/rest`). Where that is not the
14959+
* write's organization (every walled posture, degraded or not, answers
14960+
* `null`), the doors read the env-wide definition, so the write is refused
14961+
* and the author is pointed at the env-wide save, which every door
14962+
* honours. A composition with no tenancy service has no posture to judge
14963+
* (and no session to carry an organization over HTTP), so it is left as is.
14964+
*
14965+
* Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}):
14966+
* an organization-scoped edit that leaves it as the env-wide definition has
14967+
* it is unaffected. Same code and status as {@link orgScopedWriteRefusal}:
14968+
* this item's anonymous intake has no per-org channel on this deployment.
14969+
*/
14970+
private async anonymousFormIntakeOrgScopeRefusal(args: {
14971+
type: string;
14972+
name: string;
14973+
organizationId: string | null | undefined;
14974+
body: unknown;
14975+
}): Promise<Error | null> {
14976+
if (!args.organizationId) return null;
14977+
const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type;
14978+
if (singular !== 'view') return null;
14979+
const tenancy = this.getServicesRegistry?.().get('tenancy') as
14980+
| { defaultOrgId?: () => Promise<string | null> }
14981+
| undefined;
14982+
if (typeof tenancy?.defaultOrgId !== 'function') return null;
14983+
const doorOrganization = await tenancy.defaultOrgId();
14984+
if (doorOrganization === args.organizationId) return null;
14985+
const proposed = anonymousFormIntakeSlugs(args.body);
14986+
const served = anonymousFormIntakeSlugs(
14987+
((await this.getMetaItem({ type: singular, name: args.name })) as any)?.item,
14988+
);
14989+
if (proposed.length === served.length && proposed.every((s, i) => s === served[i])) return null;
14990+
const list = (slugs: string[]) => (slugs.length ? slugs.map((s) => `'${s}'`).join(', ') : 'none');
14991+
const err: any = new Error(
14992+
`Metadata item 'view/${args.name}' cannot change which public forms accept anonymous intake `
14993+
+ `in organization '${args.organizationId}' (env-wide: ${list(served)}; this write: ${list(proposed)}). `
14994+
+ `An anonymous form request carries no organization, and this deployment resolves `
14995+
+ (doorOrganization
14996+
? `it in organization '${doorOrganization}'`
14997+
: `none for it (a walled tenancy posture never guesses one)`)
14998+
+ `, so the anonymous form doors serve the env-wide definition and would never see this change. `
14999+
+ `Save it env-wide instead (retry with no active organization): that withdraws or publishes the form `
15000+
+ `on every anonymous door. An organization-scoped edit that leaves the form's sharing as the env-wide `
15001+
+ `definition has it is still accepted. See docs/adr/0005-metadata-customization-overlay.md.`
15002+
);
15003+
err.code = 'NOT_OVERRIDABLE';
15004+
err.status = 403;
15005+
err.organizationId = args.organizationId;
15006+
err.docs = 'docs/adr/0005-metadata-customization-overlay.md';
15007+
return err;
15008+
}
15009+
1494915010
/**
1495015011
* Does an artifact (npm-package-loaded) item exist at `(type, name)`?
1495115012
*
@@ -17722,6 +17783,18 @@ export class ObjectStackProtocolImplementation implements
1772217783
);
1772317784
if (orgRefusal) throw orgRefusal;
1772417785
}
17786+
// An org-scoped change to a form's anonymous intake that the anonymous
17787+
// form doors cannot see. Drafts too, so no draft is minted that its
17788+
// own promotion would refuse. See {@link anonymousFormIntakeOrgScopeRefusal}.
17789+
{
17790+
const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({
17791+
type: request.type,
17792+
name: request.name,
17793+
organizationId: request.organizationId,
17794+
body: request.item,
17795+
});
17796+
if (intakeRefusal) throw intakeRefusal;
17797+
}
1772517798

1772617799
if (this.environmentId !== undefined) {
1772717800
// [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a
@@ -19675,6 +19748,17 @@ export class ObjectStackProtocolImplementation implements
1967519748
const nameRefusal = savedItemNameRefusal(singularType, draftForGate.body, request.name, 'publish');
1967619749
if (nameRefusal) throw nameRefusal;
1967719750
}
19751+
// The promotion half of {@link anonymousFormIntakeOrgScopeRefusal}: a
19752+
// draft saved before that refusal existed must not reach `active`.
19753+
if (draftForGate) {
19754+
const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({
19755+
type: singularType,
19756+
name: request.name,
19757+
organizationId: orgId,
19758+
body: draftForGate.body,
19759+
});
19760+
if (intakeRefusal) throw intakeRefusal;
19761+
}
1967819762
// [#9176] The gate's return is its advisory half (#4717): captured and
1967919763
// handed out so `publishMetaItem` can attach it to the 2xx this
1968019764
// promotion is about to earn, exactly as `saveMetaItem` attaches its

0 commit comments

Comments
 (0)