Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/21468-walled-public-form-withdrawal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/metadata-protocol': patch
---

Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring, with `403 NOT_OVERRIDABLE`, when the save is organization-scoped and the anonymous form doors cannot honour it. The message names the remedy: save the change env-wide, which every anonymous door honours. Drafts and draft promotion are refused alike. Other organization-scoped edits, env-wide saves and single-posture deployments are unchanged.
37 changes: 37 additions & 0 deletions packages/metadata-protocol/src/anonymous-form-intake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Which public-form slugs a `view` body opens to anonymous intake.
*
* The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`,
* `registerFormEndpoints` in `@objectstack/rest`) serve a form when one of a
* view's form candidates carries `sharing.allowAnonymous === true` and a
* `sharing.publicLink` that names the slug. The candidates are the same three
* shapes those doors scan: the nested `form`, every `formViews` entry, and the
* flattened `config` of a `viewKind: 'form'` item.
*
* Returns the sorted, de-duplicated slug set, normalised the way the doors
* compare it (`/forms/x`, `forms/x` and `x` are one slug). Two bodies with the
* same set open exactly the same anonymous doors.
*/
export function anonymousFormIntakeSlugs(view: unknown): string[] {
if (!view || typeof view !== 'object') return [];
const v = view as Record<string, any>;
const sharings: unknown[] = [];
if (v.form && typeof v.form === 'object') sharings.push(v.form.sharing);
if (v.formViews && typeof v.formViews === 'object') {
for (const fv of Object.values(v.formViews)) {
if (fv && typeof fv === 'object') sharings.push((fv as any).sharing);
}
}
if (v.viewKind === 'form' && v.config && typeof v.config === 'object') sharings.push(v.config.sharing);
const slugs = new Set<string>();
for (const s of sharings) {
if (!s || typeof s !== 'object') continue;
const sharing = s as Record<string, unknown>;
if (sharing.allowAnonymous !== true) continue;
if (typeof sharing.publicLink !== 'string' || !sharing.publicLink) continue;
slugs.add(sharing.publicLink.replace(/^\/+/, '').replace(/^forms\//, ''));
}
return [...slugs].sort();
}
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,114 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused',
expect(affected).toHaveLength(17);
});
});

/**
* An org-scoped change to which public forms accept anonymous intake is
* refused when the anonymous form doors would never read that organization:
* they resolve the form in `tenancy.defaultOrgId()`'s organization, which a
* walled posture (degraded or not) answers `null`. Same stub engine as above;
* the `tenancy` service is the only addition.
*/
describe('org-scoped anonymous form intake changes the anonymous doors cannot see', () => {
const sharing = (allowAnonymous: boolean) => ({ enabled: true, allowAnonymous, publicLink: '/forms/walled-intake' });
const FORM_VIEW = (allowAnonymous: boolean, label = 'Intake') => ({
name: 'task.intake_form',
label,
object: 'task',
viewKind: 'form',
config: { sharing: sharing(allowAnonymous) },
});

/** `defaultOrgId` answers what the anonymous doors resolve. */
function makeTenancyProtocol(defaultOrgId: string | null) {
const { engine, rows } = makeStubEngine();
const services = new Map<string, unknown>([['tenancy', { defaultOrgId: async () => defaultOrgId }]]);
const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any;
return { protocol, rows };
}

async function publishEnvWide(protocol: any) {
const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(true) });
expect(res.success).toBe(true);
}

it('walled (no organization for an anonymous request): the org-scoped withdrawal is refused and nothing is saved', async () => {
const { protocol, rows } = makeTenancyProtocol(null);
await publishEnvWide(protocol);

const refusal = protocol.saveMetaItem({
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a',
});
await expect(refusal).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
await expect(refusal).rejects.toThrow(/Save it env-wide instead/);
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
});

it('walled: an org-scoped draft of the withdrawal is refused too', async () => {
const { protocol, rows } = makeTenancyProtocol(null);
await publishEnvWide(protocol);

await expect(protocol.saveMetaItem({
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', mode: 'draft',
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
});

it('walled: an org-scoped publish of a form the env-wide definition keeps private is refused', async () => {
const { protocol } = makeTenancyProtocol(null);
const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) });
expect(res.success).toBe(true);

await expect(protocol.saveMetaItem({
type: 'view', name: 'task.intake_form', item: FORM_VIEW(true), organizationId: 'org_a',
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
});

it('walled: a legacy org-scoped draft of the withdrawal cannot be promoted', async () => {
const { protocol, rows } = makeTenancyProtocol(null);
await publishEnvWide(protocol);
await seedLegacyOrgDraft(protocol, {
type: 'view', name: 'task.intake_form', body: FORM_VIEW(false), organizationId: 'org_a',
});

await expect(
protocol.publishMetaItem({ type: 'view', name: 'task.intake_form', organizationId: 'org_a' }),
).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(orgRows(rows).filter((r) => r.org === 'org_a' && r.state === 'active')).toEqual([]);
});

it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => {
const { protocol, rows } = makeTenancyProtocol(null);
await publishEnvWide(protocol);

const res = await protocol.saveMetaItem({
type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (tenant)'), organizationId: 'org_a',
});
expect(res.success).toBe(true);
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([
{ type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' },
]);
});

it('control (walled): the env-wide withdrawal is accepted', async () => {
const { protocol } = makeTenancyProtocol(null);
await publishEnvWide(protocol);

const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) });
expect(res.success).toBe(true);
expect(res.message).toContain('env-wide');
});

it('control (single): the doors resolve this organization, so the org-scoped withdrawal is accepted', async () => {
const { protocol, rows } = makeTenancyProtocol('org_a');
await publishEnvWide(protocol);

const res = await protocol.saveMetaItem({
type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a',
});
expect(res.success).toBe(true);
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([
{ type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' },
]);
});
});
84 changes: 84 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
// [#7560] ADR-0070's read-only-package rule, shared with the `/packages`
// lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`.
import { isWritablePackage as isWritablePackageShared } from './package-writability.js';
import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js';
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
// [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
Expand Down Expand Up @@ -14946,6 +14947,66 @@ export class ObjectStackProtocolImplementation implements
return err;
}

/**
* An organization-scoped `view` write that changes which public forms
* accept anonymous intake, on a deployment whose anonymous form doors do
* not read that organization. Returns the refusal, or `null` when the
* write is fine.
*
* An anonymous form request carries no session and so no organization.
* The doors resolve the form in `tenancy.defaultOrgId()`'s organization
* (`registerFormEndpoints` in `@objectstack/rest`). Where that is not the
* write's organization (every walled posture, degraded or not, answers
* `null`), the doors read the env-wide definition, so the write is refused
* and the author is pointed at the env-wide save, which every door
* honours. A composition with no tenancy service has no posture to judge
* (and no session to carry an organization over HTTP), so it is left as is.
*
* Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}):
* an organization-scoped edit that leaves it as the env-wide definition has
* it is unaffected. Same code and status as {@link orgScopedWriteRefusal}:
* this item's anonymous intake has no per-org channel on this deployment.
*/
private async anonymousFormIntakeOrgScopeRefusal(args: {
type: string;
name: string;
organizationId: string | null | undefined;
body: unknown;
}): Promise<Error | null> {
if (!args.organizationId) return null;
const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type;
if (singular !== 'view') return null;
const tenancy = this.getServicesRegistry?.().get('tenancy') as
| { defaultOrgId?: () => Promise<string | null> }
| undefined;
if (typeof tenancy?.defaultOrgId !== 'function') return null;
const doorOrganization = await tenancy.defaultOrgId();
if (doorOrganization === args.organizationId) return null;
const proposed = anonymousFormIntakeSlugs(args.body);
const served = anonymousFormIntakeSlugs(
((await this.getMetaItem({ type: singular, name: args.name })) as any)?.item,
);
if (proposed.length === served.length && proposed.every((s, i) => s === served[i])) return null;
const list = (slugs: string[]) => (slugs.length ? slugs.map((s) => `'${s}'`).join(', ') : 'none');
const err: any = new Error(
`Metadata item 'view/${args.name}' cannot change which public forms accept anonymous intake `
+ `in organization '${args.organizationId}' (env-wide: ${list(served)}; this write: ${list(proposed)}). `
+ `An anonymous form request carries no organization, and this deployment resolves `
+ (doorOrganization
? `it in organization '${doorOrganization}'`
: `none for it (a walled tenancy posture never guesses one)`)
+ `, so the anonymous form doors serve the env-wide definition and would never see this change. `
+ `Save it env-wide instead (retry with no active organization): that withdraws or publishes the form `
+ `on every anonymous door. An organization-scoped edit that leaves the form's sharing as the env-wide `
+ `definition has it is still accepted. See docs/adr/0005-metadata-customization-overlay.md.`
);
err.code = 'NOT_OVERRIDABLE';
err.status = 403;
err.organizationId = args.organizationId;
err.docs = 'docs/adr/0005-metadata-customization-overlay.md';
return err;
}

/**
* Does an artifact (npm-package-loaded) item exist at `(type, name)`?
*
Expand Down Expand Up @@ -17722,6 +17783,18 @@ export class ObjectStackProtocolImplementation implements
);
if (orgRefusal) throw orgRefusal;
}
// An org-scoped change to a form's anonymous intake that the anonymous
// form doors cannot see. Drafts too, so no draft is minted that its
// own promotion would refuse. See {@link anonymousFormIntakeOrgScopeRefusal}.
{
const intakeRefusal = await this.anonymousFormIntakeOrgScopeRefusal({
type: request.type,
name: request.name,
organizationId: request.organizationId,
body: request.item,
});
if (intakeRefusal) throw intakeRefusal;
}

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