From 688c8071e39a19ff7c31d51614c37249d2f797b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:10:14 +0000 Subject: [PATCH 01/10] wip: public form doors resolve in the request's organization Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 70 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 765ce8c9990..d9839fb390e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -15,6 +15,9 @@ import { // every other rejection stays loud. ⛔ The two wirings themselves are NOT // the helper's — see `computeExecCtx`. classifyAdmissionTenancyPosture, + // The registry's "never registered" brand, asked directly by the public + // form doors' tenancy read (see `registerFormEndpoints`). + isServiceNotRegisteredError, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one @@ -10584,16 +10587,74 @@ export class RestServer { return null; }; + // [#21331] WHICH organization's metadata an anonymous form request + // reads. A public-form request carries no session, so it carries no + // active organization, and `getMetaItems` without one merges only the + // env-wide overlays. An administrator's edit of a packaged form is + // saved as an overlay of THEIR organization, so that read missed every + // such edit, including the one that withdraws the form from anonymous + // intake. The editor showed the form closed while both doors kept + // serving and accepting it. + // + // The answer is the tenancy service's `defaultOrgId()`: the + // organization a single-posture deployment binds every principal to. + // It is the organization the administrator's own session is in, and + // the one the engine stamps on the row this request inserts. It is + // `undefined` in two cases. Before any organization exists, no + // organization overlay can exist either. A walled posture has no + // install organization for an org-less request, so the env-wide state + // governs there exactly as before. + // + // Asked ONCE per request, in `resolveFormBySlug`. Every door below + // reads the form through that one resolution, so no door keeps its + // own copy of "is this form public". + // + // Fails CLOSED. A tenancy service that is registered but cannot be + // reached raises `AuthzStoreUnavailableError`, the classification + // `classifyAdmissionTenancyPosture` applies to the same seam. The + // door then refuses instead of falling back to the env-wide read. + // Only the registry's own "never registered" brand reads as the + // supported no-tenancy composition. The wiring mirrors + // `resolveProtocol`, so the tenancy service and the protocol always + // come from the same kernel. + const resolveFormOrganization = async ( + environmentId: string | undefined, + req: any, + ): Promise => { + let tenancy: any; + try { + const envId = environmentId === 'platform' + ? undefined + : await this.resolveRequestEnvironmentId(environmentId, req); + if (envId && this.kernelManager) { + const kernel: any = await this.kernelManager.getOrCreate(envId); + tenancy = typeof kernel?.getServiceAsync === 'function' + ? await kernel.getServiceAsync('tenancy') + : undefined; + } else if (this.tenancyServiceProvider) { + tenancy = await this.tenancyServiceProvider(environmentId); + } + } catch (err) { + if (isServiceNotRegisteredError(err)) return undefined; + throw new AuthzStoreUnavailableError('tenancy', err); + } + if (!tenancy || typeof tenancy.defaultOrgId !== 'function') return undefined; + const organizationId = await tenancy.defaultOrgId(); + return typeof organizationId === 'string' && organizationId ? organizationId : undefined; + }; + const resolveFormBySlug = async ( environmentId: string | undefined, req: any, slug: string, - ): Promise<{ view: any; form: any; object: string } | null> => { + ): Promise<{ view: any; form: any; object: string; organizationId: string | undefined } | null> => { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; + const organizationId = await resolveFormOrganization(environmentId, req); const viewsRequest: TransportScopedMetaRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), + ...(organizationId ? { organizationId } : {}), }; const result: any = await p.getMetaItems(viewsRequest); const items: any[] = Array.isArray(result?.items) @@ -10601,7 +10662,8 @@ export class RestServer { : Array.isArray(result) ? result : []; - return findPublicFormView(items, slug); + const match = findPublicFormView(items, slug); + return match ? { ...match, organizationId } : null; }; // GET /forms/:slug — resolve and return the public form spec @@ -10656,9 +10718,13 @@ export class RestServer { try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { + // [#21331] The same organization the form itself + // was resolved in, so the published field schema + // matches the form the caller was served. const objectsRequest: TransportScopedMetaRequest = { type: 'object', ...(environmentId ? { environmentId } : {}), + ...(match.organizationId ? { organizationId: match.organizationId } : {}), }; const r: any = await p.getMetaItems(objectsRequest); const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; From 28adcb15876dbf49502a28377b3a076546990abb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:13:10 +0000 Subject: [PATCH 02/10] test(rest): pin public form withdrawal on both intake doors Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../rest/src/public-form-withdrawal.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/rest/src/public-form-withdrawal.test.ts diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts new file mode 100644 index 00000000000..dbef4589bb6 --- /dev/null +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21331] Withdrawing a public form from anonymous intake takes effect on +// every intake door. Both doors (`GET /forms/:slug` and +// `POST /forms/:slug/submit`) read the form in the organization the request +// belongs to: the tenancy service's `defaultOrgId()`. That is the same +// organization whose overlay the administrator's editor shows. A withdrawal +// saved in that organization therefore closes both doors, and nothing is +// written. +// +// Pinned both sides: +// - withdrawn in the organization: both doors answer 404 FORM_NOT_FOUND and +// `createData` is never called; +// - published in the organization (the control): both doors accept, and +// every metadata read the doors make names that organization; +// - 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). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at +// MODULE LOAD rather than inside a clocked `it()` body. +import '@objectstack/spec/ui'; + +const ORG = 'org_alpha'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.end = vi.fn(() => res); + return res; +} + +function formView(allowAnonymous: boolean) { + return { + name: 'contact', + object: 'inquiry', + viewKind: 'form', + config: { + data: { object: 'inquiry' }, + sections: [{ fields: ['name', 'email'] }], + sharing: { allowAnonymous, publicLink: '/forms/contact-us' }, + }, + }; +} + +const inquiryObject = { + name: 'inquiry', + label: 'Inquiry', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + }, +}; + +/** Reproduces the registry's own "never registered" rejection. */ +function notRegistered(): Error { + return Object.assign(new Error("Service 'tenancy' not found"), { + __objectstackServiceNotRegistered: true, + code: 'SERVICE_NOT_REGISTERED', + serviceName: 'tenancy', + }); +} + +interface Setup { + /** What the env-wide (organization-less) read answers. */ + envWide: boolean; + /** What the organization read answers; `undefined` = no overlay there. */ + inOrg?: boolean; + /** The tenancy provider's behaviour. */ + tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; +} + +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') { + const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; + return [formView(effective)]; + } + if (req.type === 'object') return [inquiryObject]; + return []; + }); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems, + createData, + }; + const tenancyServiceProvider = async () => { + switch (setup.tenancy) { + case 'org': return { posture: 'single', defaultOrgId: async () => ORG }; + case 'no-org': return { posture: 'single', defaultOrgId: async () => null }; + case 'not-registered': throw notRegistered(); + case 'unreachable': throw new Error('tenancy service failed to construct'); + } + }; + const rest = new RestServer( + mockServer() as any, protocol, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + tenancyServiceProvider, + ); + rest.registerRoutes(); + const find = (method: string, suffix: string) => + rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!; + const resolve = find('GET', '/forms/:slug'); + const submit = find('POST', '/forms/:slug/submit'); + return { + createData, + getMetaItems, + async get() { + const res = mockRes(); + await resolve.handler({ params: { slug: 'contact-us' }, query: {}, headers: {} } as any, res); + return res; + }, + async post() { + const res = mockRes(); + await submit.handler( + { params: { slug: 'contact-us' }, query: {}, headers: {}, body: { name: 'x', email: 'x@example.com' } } as any, + res, + ); + return res; + }, + }; +} + +describe('[#21331] public form withdrawal reaches every intake door', () => { + it('withdrawn in the organization: both doors answer FORM_NOT_FOUND and nothing is written', async () => { + const s = build({ envWide: true, inOrg: false, tenancy: 'org' }); + const get = await s.get(); + expect(get.statusCode).toBe(404); + expect(get.body.code).toBe('FORM_NOT_FOUND'); + const post = await s.post(); + expect(post.statusCode).toBe(404); + expect(post.body.code).toBe('FORM_NOT_FOUND'); + expect(s.createData).not.toHaveBeenCalled(); + }); + + it('published in the organization (control): both doors accept, every read names the organization', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + const get = await s.get(); + expect(get.statusCode).toBe(200); + expect(get.body.object).toBe('inquiry'); + expect(Object.keys(get.body.objectSchema.fields).sort()).toEqual(['email', 'name']); + const post = await s.post(); + expect(post.statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); + 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); + }); + + it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => { + const s = build({ envWide: false, tenancy: 'no-org' }); + expect((await s.get()).body.code).toBe('FORM_NOT_FOUND'); + expect((await s.post()).body.code).toBe('FORM_NOT_FOUND'); + expect(s.createData).not.toHaveBeenCalled(); + for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + }); + + it('tenancy never registered: the env-wide read, unchanged', async () => { + const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + }); + + it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => { + const s = build({ envWide: true, tenancy: 'unreachable' }); + const get = await s.get(); + expect(get.statusCode).toBe(500); + expect(get.body.code).toBe('FORM_RESOLVE_FAILED'); + const post = await s.post(); + expect(post.statusCode).toBe(503); + expect(post.body.code).toBe('SERVICE_UNAVAILABLE'); + expect(s.createData).not.toHaveBeenCalled(); + expect(s.getMetaItems).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'view' })); + }); +}); From 7e9b309cf6fa79a15d15ff77f0876399ea8a3d2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:16:47 +0000 Subject: [PATCH 03/10] test(dogfood): pin public form withdrawal on a live showcase boot Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- ...ase-public-form-withdrawal.dogfood.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts new file mode 100644 index 00000000000..582ea10a435 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21331] Withdrawing a public form from anonymous intake takes effect on +// every intake door, on a real showcase boot. +// +// The showcase ships `showcase_inquiry.contact`, a FormView with +// `sharing.allowAnonymous: true` at `/forms/contact-us` +// (`showcase-public-form.dogfood.test.ts` pins that it serves). An +// administrator withdraws it the way the editor does, with `PUT /meta/view/…` +// at their own session. The admin is org-bound here (`orgContext: true`), so +// the save is an overlay of their organization. Both anonymous doors must then +// refuse, and nothing may land: +// +// - `GET /forms/contact-us` and `POST /forms/contact-us/submit` both answer +// `404 FORM_NOT_FOUND`; +// - no `showcase_inquiry` row is written by the refused submit. +// +// Both sides are pinned. Republishing in the same organization restores both +// doors, and the row lands in that organization (the control). A withdrawal +// saved env-wide (the admin with no active organization) closes both doors +// too, while an organization overlay that still publishes is not overruled by +// it. + +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; + +interface Probe { + get: number; + getCode: unknown; + submit: number; + submitCode: unknown; + landed: Array<{ organization_id?: unknown }>; +} + +describe('showcase: withdrawing the public contact form closes every intake door', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let published: Record; + let probeSeq = 0; + + /** Both anonymous doors, plus the rows a submit with a unique marker left. */ + const probe = async (): Promise => { + const marker = `withdrawal_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 { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed }; + }; + + /** Save the form with `allowAnonymous` set, at the admin's current session scope. */ + const save = async (allowAnonymous: boolean): Promise => { + const body = structuredClone(published); + body.config.sharing.allowAnonymous = allowAnonymous; + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const json = (await res.json()) as { message?: string }; + expect(res.status, JSON.stringify(json)).toBe(200); + return String(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; + // The editor's PUT body: the effective item without its read decorations. + published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + expect(published.config?.sharing?.allowAnonymous).toBe(true); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: the published form accepts anonymous intake', async () => { + const p = await probe(); + expect(p.get).toBe(200); + expect(p.submit).toBe(201); + expect(p.landed).toHaveLength(1); + }); + + it('withdrawn env-wide: both doors answer 404 FORM_NOT_FOUND and nothing lands; republishing restores them', async () => { + const off = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: null }); + expect(off.status).toBe(200); + expect(await save(false)).toMatch(/env-wide/); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); + + expect(await save(true)).toMatch(/env-wide/); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); + }); + + it('withdrawn in the admin\'s organization: both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => { + const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS }); + expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1); + const on = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: orgs[0].id }); + expect(on.status).toBe(200); + + expect(await save(false), 'the save is an organization overlay').toContain(`org=${orgs[0].id}`); + const p = await probe(); + expect([p.get, p.getCode, p.submit, p.submitCode]).toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(p.landed).toHaveLength(0); + }); + + it('republished in the same organization (control): both doors accept and the row lands in that organization', async () => { + const message = await save(true); + const org = /org=(\S+?),/.exec(message)?.[1]; + expect(org, message).toBeTruthy(); + const p = await probe(); + expect([p.get, p.submit]).toEqual([200, 201]); + expect(p.landed).toHaveLength(1); + expect(p.landed[0].organization_id).toBe(org); + }); +}); From c2ac3e2e2a90c1201e9088a621f1f177a568df50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:21:14 +0000 Subject: [PATCH 04/10] chore: changeset and dogfood header for public form withdrawal Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/21331-public-form-withdrawal.md | 5 +++++ ...howcase-public-form-withdrawal.dogfood.test.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 .changeset/21331-public-form-withdrawal.md diff --git a/.changeset/21331-public-form-withdrawal.md b/.changeset/21331-public-form-withdrawal.md new file mode 100644 index 00000000000..0a8d34e5f1b --- /dev/null +++ b/.changeset/21331-public-form-withdrawal.md @@ -0,0 +1,5 @@ +--- +'@objectstack/rest': patch +--- + +Withdrawing a public form from anonymous intake now takes effect on every intake door. The anonymous form routes (`GET /forms/:slug` and `POST /forms/:slug/submit`) read the form in the same scope an administrator's form editor shows, so a form an administrator has withdrawn is answered `404 FORM_NOT_FOUND` on both routes and no record is created. Republishing the form restores both routes. If the deployment's tenancy service is registered but cannot be reached, both routes now refuse the request. They no longer serve the form from a partial read. diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts index 582ea10a435..c844f68bcd0 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -6,20 +6,17 @@ // The showcase ships `showcase_inquiry.contact`, a FormView with // `sharing.allowAnonymous: true` at `/forms/contact-us` // (`showcase-public-form.dogfood.test.ts` pins that it serves). An -// administrator withdraws it the way the editor does, with `PUT /meta/view/…` -// at their own session. The admin is org-bound here (`orgContext: true`), so -// the save is an overlay of their organization. Both anonymous doors must then -// refuse, and nothing may land: +// administrator withdraws it the way the editor does, with `PUT /meta/view/...` +// at their own session, once env-wide (no active organization) and once in +// their organization (`orgContext: true` gives the admin one). After either +// withdrawal both anonymous doors must refuse, and nothing may land: // // - `GET /forms/contact-us` and `POST /forms/contact-us/submit` both answer // `404 FORM_NOT_FOUND`; // - no `showcase_inquiry` row is written by the refused submit. // -// Both sides are pinned. Republishing in the same organization restores both -// doors, and the row lands in that organization (the control). A withdrawal -// saved env-wide (the admin with no active organization) closes both doors -// too, while an organization overlay that still publishes is not overruled by -// it. +// Both sides are pinned: republishing at the same scope restores both doors, +// and after the organization republish the row lands in that organization. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; From 4d4d342a3e8464df82c364bb23d290b62de30bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:21:29 +0000 Subject: [PATCH 05/10] chore: changeset wording Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/21331-public-form-withdrawal.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/21331-public-form-withdrawal.md b/.changeset/21331-public-form-withdrawal.md index 0a8d34e5f1b..6ee260e57de 100644 --- a/.changeset/21331-public-form-withdrawal.md +++ b/.changeset/21331-public-form-withdrawal.md @@ -2,4 +2,8 @@ '@objectstack/rest': patch --- -Withdrawing a public form from anonymous intake now takes effect on every intake door. The anonymous form routes (`GET /forms/:slug` and `POST /forms/:slug/submit`) read the form in the same scope an administrator's form editor shows, so a form an administrator has withdrawn is answered `404 FORM_NOT_FOUND` on both routes and no record is created. Republishing the form restores both routes. If the deployment's tenancy service is registered but cannot be reached, both routes now refuse the request. They no longer serve the form from a partial read. +Withdrawing a public form from anonymous intake now takes effect on every intake door + +Clause-②: no + +When an administrator withdraws a public form, both anonymous form routes (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer `404 FORM_NOT_FOUND` and no record is created. Republishing the form restores both routes. If a service the routes need to resolve the form is registered but cannot be reached, both routes refuse the request instead of serving the form. From e6aaab8334c2aaee748378c69e97ac16f78a1f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:17:56 +0000 Subject: [PATCH 06/10] wip: refuse an org-scoped anonymous form intake change the doors cannot see Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/anonymous-form-intake.ts | 37 ++++++++ packages/metadata-protocol/src/protocol.ts | 86 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 packages/metadata-protocol/src/anonymous-form-intake.ts diff --git a/packages/metadata-protocol/src/anonymous-form-intake.ts b/packages/metadata-protocol/src/anonymous-form-intake.ts new file mode 100644 index 00000000000..5a912cd4cff --- /dev/null +++ b/packages/metadata-protocol/src/anonymous-form-intake.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which public-form slugs a `view` body opens to anonymous intake. + * + * The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`, + * `registerFormEndpoints` in `@objectstack/rest`) serve a form when one of a + * view's form candidates carries `sharing.allowAnonymous === true` and a + * `sharing.publicLink` that names the slug. The candidates are the same three + * shapes those doors scan: the nested `form`, every `formViews` entry, and the + * flattened `config` of a `viewKind: 'form'` item. + * + * Returns the sorted, de-duplicated slug set, normalised the way the doors + * compare it (`/forms/x`, `forms/x` and `x` are one slug). Two bodies with the + * same set open exactly the same anonymous doors. + */ +export function anonymousFormIntakeSlugs(view: unknown): string[] { + if (!view || typeof view !== 'object') return []; + const v = view as Record; + const sharings: unknown[] = []; + if (v.form && typeof v.form === 'object') sharings.push(v.form.sharing); + if (v.formViews && typeof v.formViews === 'object') { + for (const fv of Object.values(v.formViews)) { + if (fv && typeof fv === 'object') sharings.push((fv as any).sharing); + } + } + if (v.viewKind === 'form' && v.config && typeof v.config === 'object') sharings.push(v.config.sharing); + const slugs = new Set(); + for (const s of sharings) { + if (!s || typeof s !== 'object') continue; + const sharing = s as Record; + if (sharing.allowAnonymous !== true) continue; + if (typeof sharing.publicLink !== 'string' || !sharing.publicLink) continue; + slugs.add(sharing.publicLink.replace(/^\/+/, '').replace(/^forms\//, '')); + } + return [...slugs].sort(); +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a0c0a98f6a6..f351444dd2c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -38,6 +38,7 @@ import { // [#7560] ADR-0070's read-only-package rule, shared with the `/packages` // lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`. import { isWritablePackage as isWritablePackageShared } from './package-writability.js'; +import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js'; import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. @@ -14517,6 +14518,68 @@ 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`; so does a composition with no tenancy service), the doors read + * the env-wide definition and never see this overlay. Saving it would show + * the author a withdrawn (or published) form while the doors kept serving + * the env-wide state, so the write is refused instead and the author is + * pointed at the env-wide save, which every door honours. + * + * Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}): + * an organization-scoped edit that leaves it as the env-wide definition has + * it is unaffected. Same code and status as {@link orgScopedWriteRefusal}: + * this item's anonymous intake has no per-org channel on this deployment. + */ + private async anonymousFormIntakeOrgScopeRefusal(args: { + type: string; + name: string; + organizationId: string | null | undefined; + body: unknown; + }): Promise { + if (!args.organizationId) return null; + const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; + if (singular !== 'view') return null; + const tenancy = this.getServicesRegistry?.().get('tenancy') as + | { defaultOrgId?: () => Promise } + | undefined; + const doorOrganization = typeof tenancy?.defaultOrgId === 'function' + ? await tenancy.defaultOrgId() + : null; + 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)`? * @@ -17032,6 +17095,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 @@ -18877,6 +18952,17 @@ export class ObjectStackProtocolImplementation implements { type: singularType, name: request.name, org: orgId ?? 'env' } as Parameters[0], { state: 'draft' }, ); + // 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 From de5938e9117dfbe8f5a8ac05b3039fec750bf918 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:21:31 +0000 Subject: [PATCH 07/10] wip: walled-posture dogfood pin for public form withdrawal Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- ...lic-form-withdrawal-walled.dogfood.test.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts new file mode 100644 index 00000000000..1522dc2346f --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Withdrawing a public form from anonymous intake on a WALLED tenancy posture, +// on a real showcase boot. The single-posture half lives in +// `showcase-public-form-withdrawal.dogfood.test.ts`. +// +// An anonymous form request carries no organization, and a walled posture +// resolves none for it, so the anonymous form doors read the env-wide form +// definition. An organization-scoped change to a form's anonymous intake +// would therefore be shown in the editor and never reach a door; the save +// door refuses it instead, naming the env-wide save as the remedy. Pinned: +// +// - the organization-scoped withdrawal answers `403 NOT_OVERRIDABLE` and +// nothing is saved (the organization still reads the published form, and +// both doors still serve it); +// - an organization-scoped edit that leaves the sharing alone still saves; +// - the env-wide withdrawal is accepted and both anonymous doors answer +// `404 FORM_NOT_FOUND`, with no row landing; republishing env-wide restores +// both doors. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import 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; + +interface Probe { + get: number; + getCode: unknown; + submit: number; + submitCode: unknown; + landed: unknown[]; +} + +describe('showcase on a walled posture: withdrawing the public contact form', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let published: Record; + let orgId: string; + let probeSeq = 0; + + /** Both anonymous doors, plus the rows a submit with a unique marker left. */ + const probe = async (): Promise => { + const marker = `walled_withdrawal_probe_${++probeSeq}`; + const get = await stack.api('/forms/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 { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed }; + }; + + const setActive = async (organizationId: string | null): Promise => { + const res = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + /** PUT the form at the admin's current session scope. */ + const put = async (body: Record) => { + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + const json = (await res.json()) as Record; + return { status: res.status, json }; + }; + + const withAnonymous = (allowAnonymous: boolean): Record => { + const body = structuredClone(published); + body.config.sharing.allowAnonymous = allowAnonymous; + return body; + }; + + /** The effective form as the admin's current scope reads it. */ + const read = async (): Promise> => { + const res = await stack.apiAs(admin, 'GET', VIEW); + expect(res.status).toBe(200); + const json = (await res.json()) as { item?: Record }; + return (json.item ?? json) as Record; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + multiTenant: 'posture-only', + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const item = await read(); + // The editor's PUT body: the effective item without its read decorations. + published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + expect(published.config?.sharing?.allowAnonymous).toBe(true); + + const created = await stack.apiAs(admin, 'POST', '/auth/organization/create', { + name: 'Walled Tenant', slug: 'walled-tenant-form', + }); + expect(created.status, await created.clone().text()).toBe(200); + orgId = ((await created.json()) as { id: string }).id; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: a walled posture that resolves no organization for an anonymous request', async () => { + const tenancy = stack.tenancy(); + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.requested).toBe(true); + expect(await tenancy.defaultOrgId()).toBeNull(); + expect(orgId).toBeTruthy(); + }); + + it('PRECONDITION: the published form accepts anonymous intake', async () => { + const p = await probe(); + expect([p.get, p.submit]).toEqual([200, 201]); + expect(p.landed).toHaveLength(1); + }); + + it('withdrawn in an organization: refused 403 NOT_OVERRIDABLE naming the env-wide save, and nothing is saved', async () => { + await setActive(orgId); + const res = await put(withAnonymous(false)); + expect(res.status, JSON.stringify(res.json)).toBe(403); + const error = (res.json.error ?? res.json) as Record; + expect(error.code ?? res.json.code).toBe('NOT_OVERRIDABLE'); + expect(JSON.stringify(res.json)).toMatch(/Save it env-wide instead/); + + expect((await read()).config?.sharing?.allowAnonymous, 'the organization still reads the published form').toBe(true); + const p = await probe(); + expect([p.get, p.submit]).toEqual([200, 201]); + expect(p.landed).toHaveLength(1); + }); + + it('an organization-scoped edit that leaves the sharing alone still saves (control)', async () => { + await setActive(orgId); + const body = structuredClone(published); + body.label = `${String(published.label ?? 'Contact')} (tenant)`; + const res = await put(body); + expect(res.status, JSON.stringify(res.json)).toBe(200); + expect(String(res.json.message ?? '')).toContain(`org=${orgId}`); + }); + + it('withdrawn env-wide: both doors answer 404 FORM_NOT_FOUND and nothing lands; republishing restores them', async () => { + await setActive(null); + const off = await put(withAnonymous(false)); + expect(off.status, JSON.stringify(off.json)).toBe(200); + expect(String(off.json.message ?? '')).toMatch(/env-wide/); + const closed = await probe(); + expect([closed.get, closed.getCode, closed.submit, closed.submitCode]) + .toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']); + expect(closed.landed).toHaveLength(0); + + const on = await put(withAnonymous(true)); + expect(on.status, JSON.stringify(on.json)).toBe(200); + const open = await probe(); + expect([open.get, open.submit]).toEqual([200, 201]); + expect(open.landed).toHaveLength(1); + }); +}); From 873c7ddb4aae1bd7e0225136650623d5163d0cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:31:25 +0000 Subject: [PATCH 08/10] fix(metadata-protocol): refuse an org-scoped public form intake change the anonymous doors cannot see Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../21468-walled-public-form-withdrawal.md | 5 + .../protocol.org-scoped-write-refused.test.ts | 121 ++++++++++++++++++ ...ic-form-withdrawal-walled.dogfood.test.ts} | 65 ++++++++-- 3 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 .changeset/21468-walled-public-form-withdrawal.md rename packages/qa/dogfood/test/{showcase-public-form-withdrawal-walled.dogfood.test.ts => public-form-withdrawal-walled.dogfood.test.ts} (75%) diff --git a/.changeset/21468-walled-public-form-withdrawal.md b/.changeset/21468-walled-public-form-withdrawal.md new file mode 100644 index 00000000000..6abccc6643e --- /dev/null +++ b/.changeset/21468-walled-public-form-withdrawal.md @@ -0,0 +1,5 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +An organization-scoped save that changes which public forms accept anonymous intake is now refused (`403 NOT_OVERRIDABLE`) when the anonymous form doors never read that organization, as on a walled tenancy posture (degraded or not), where an anonymous request resolves no organization. The message names the reason and the remedy: save the change env-wide, which withdraws or publishes the form on every anonymous door. Drafts and draft promotion are refused alike. Organization-scoped edits that leave a form's anonymous intake as the env-wide definition has it, env-wide saves, and single-posture deployments are unchanged. diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 4764a001df9..2a0f1211ed0 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -561,3 +561,124 @@ 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; `undefined` = no tenancy service. */ + function makeTenancyProtocol(defaultOrgId: string | null | undefined) { + const { engine, rows } = makeStubEngine(); + const services = new Map(); + if (defaultOrgId !== undefined) services.set('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('no tenancy service: the doors read env-wide, so the org-scoped withdrawal is refused', async () => { + const { protocol } = makeTenancyProtocol(undefined); + await publishEnvWide(protocol); + + await expect(protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => { + const { protocol, rows } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(true, 'Intake (tenant)'), organizationId: 'org_a', + }); + expect(res.success).toBe(true); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ + { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, + ]); + }); + + it('control (walled): the env-wide withdrawal is accepted', async () => { + const { protocol } = makeTenancyProtocol(null); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ type: 'view', name: 'task.intake_form', item: FORM_VIEW(false) }); + expect(res.success).toBe(true); + expect(res.message).toContain('env-wide'); + }); + + it('control (single): the doors resolve this organization, so the org-scoped withdrawal is accepted', async () => { + const { protocol, rows } = makeTenancyProtocol('org_a'); + await publishEnvWide(protocol); + + const res = await protocol.saveMetaItem({ + type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', + }); + expect(res.success).toBe(true); + expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([ + { type: 'view', name: 'task.intake_form', org: 'org_a', state: 'active' }, + ]); + }); +}); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts similarity index 75% rename from packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts rename to packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts index 1522dc2346f..6fc1ff7f991 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-walled.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -1,9 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // Withdrawing a public form from anonymous intake on a WALLED tenancy posture, -// on a real showcase boot. The single-posture half lives in +// on a real boot. The single-posture half lives in // `showcase-public-form-withdrawal.dogfood.test.ts`. // +// The form here is a fixture bound to an object declared `tenancy: { enabled: +// false }`. The showcase's own contact form targets a tenant-scoped object, +// and on a walled posture the engine refuses an org-less insert into one, so +// it could not show the published side accepting intake. +// // An anonymous form request carries no organization, and a walled posture // resolves none for it, so the anonymous form doors read the env-wide form // definition. An organization-scoped change to a form's anonymous intake @@ -19,11 +24,51 @@ // both doors. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack, defineView } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; -const VIEW = '/meta/view/showcase_inquiry.contact'; +const LEAD = 'walled_lead'; +const SLUG = 'walled-lead-intake'; +const VIEW = `/meta/view/${LEAD}.intake`; + +const WalledLead = ObjectSchema.create({ + name: LEAD, + label: 'Walled Lead', + pluralLabel: 'Walled Leads', + sharingModel: 'private', + tenancy: { enabled: false }, + fields: { + name: Field.text({ label: 'Name', required: true, maxLength: 120 }), + }, +}); + +const data = { provider: 'object' as const, object: LEAD }; +const WalledLeadViews = defineView({ + list: { label: 'Leads', type: 'grid', data, columns: [{ field: 'name' }] }, + formViews: { + intake: { + type: 'simple', + data, + sections: [{ name: 'intake', label: 'Intake', columns: 1, fields: [{ field: 'name', required: true }] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}` }, + }, + }, +}); + +const walledFormStack = defineStack({ + manifest: { + id: 'com.dogfood.walled-public-form', + namespace: 'walled', + version: '0.0.0', + type: 'app', + name: 'Walled Public Form Fixture', + description: 'One tenancy-disabled object behind an anonymous form, booted on a walled posture.', + }, + objects: [WalledLead], + views: [WalledLeadViews], +}); const SYS = { isSystem: true } as const; interface Probe { @@ -34,7 +79,7 @@ interface Probe { landed: unknown[]; } -describe('showcase on a walled posture: withdrawing the public contact form', () => { +describe('walled posture: withdrawing a public form from anonymous intake', () => { let stack: VerifyStack; let admin: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -46,15 +91,15 @@ describe('showcase on a walled posture: withdrawing the public contact form', () /** Both anonymous doors, plus the rows a submit with a unique marker left. */ const probe = async (): Promise => { const marker = `walled_withdrawal_probe_${++probeSeq}`; - const get = await stack.api('/forms/contact-us'); + const get = await stack.api(`/forms/${SLUG}`); const getBody = (await get.json()) as { code?: unknown }; - const submit = await stack.api('/forms/contact-us/submit', { + const submit = await stack.api(`/forms/${SLUG}/submit`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: marker, email: 'probe@example.com', message: 'probe' }), + body: JSON.stringify({ name: marker }), }); const submitBody = (await submit.json()) as { code?: unknown }; - const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); + const landed = await ql.find(LEAD, { where: { name: marker }, context: SYS }); return { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed }; }; @@ -85,7 +130,7 @@ describe('showcase on a walled posture: withdrawing the public contact form', () }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootStack(walledFormStack as Parameters[0], { multiTenant: 'posture-only', security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); @@ -138,7 +183,7 @@ describe('showcase on a walled posture: withdrawing the public contact form', () it('an organization-scoped edit that leaves the sharing alone still saves (control)', async () => { await setActive(orgId); const body = structuredClone(published); - body.label = `${String(published.label ?? 'Contact')} (tenant)`; + body.label = `${String(published.label ?? 'Intake')} (tenant)`; const res = await put(body); expect(res.status, JSON.stringify(res.json)).toBe(200); expect(String(res.json.message ?? '')).toContain(`org=${orgId}`); From 494e770705f11e79037232505a551e70fecdaa8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:31:50 +0000 Subject: [PATCH 09/10] chore: neutral wording for the walled public form pins and changeset Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .changeset/21468-walled-public-form-withdrawal.md | 2 +- packages/metadata-protocol/src/protocol.ts | 4 +--- .../test/public-form-withdrawal-walled.dogfood.test.ts | 8 +++----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.changeset/21468-walled-public-form-withdrawal.md b/.changeset/21468-walled-public-form-withdrawal.md index 6abccc6643e..6c75c77fe62 100644 --- a/.changeset/21468-walled-public-form-withdrawal.md +++ b/.changeset/21468-walled-public-form-withdrawal.md @@ -2,4 +2,4 @@ '@objectstack/metadata-protocol': patch --- -An organization-scoped save that changes which public forms accept anonymous intake is now refused (`403 NOT_OVERRIDABLE`) when the anonymous form doors never read that organization, as on a walled tenancy posture (degraded or not), where an anonymous request resolves no organization. The message names the reason and the remedy: save the change env-wide, which withdraws or publishes the form on every anonymous door. Drafts and draft promotion are refused alike. Organization-scoped edits that leave a form's anonymous intake as the env-wide definition has it, env-wide saves, and single-posture deployments are unchanged. +Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring, with `403 NOT_OVERRIDABLE`, when the save is organization-scoped and the anonymous form doors cannot honour it. The message names the remedy: save the change env-wide, which every anonymous door honours. Drafts and draft promotion are refused alike. Other organization-scoped edits, env-wide saves and single-posture deployments are unchanged. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f351444dd2c..3d2dfe62fa5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -14529,9 +14529,7 @@ export class ObjectStackProtocolImplementation implements * (`registerFormEndpoints` in `@objectstack/rest`). Where that is not the * write's organization (every walled posture, degraded or not, answers * `null`; so does a composition with no tenancy service), the doors read - * the env-wide definition and never see this overlay. Saving it would show - * the author a withdrawn (or published) form while the doors kept serving - * the env-wide state, so the write is refused instead and the author is + * the env-wide definition, so the write is refused and the author is * pointed at the env-wide save, which every door honours. * * Judged on the anonymous slug set alone ({@link anonymousFormIntakeSlugs}): diff --git a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts index 6fc1ff7f991..f68e0e4d264 100644 --- a/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts @@ -9,11 +9,9 @@ // and on a walled posture the engine refuses an org-less insert into one, so // it could not show the published side accepting intake. // -// An anonymous form request carries no organization, and a walled posture -// resolves none for it, so the anonymous form doors read the env-wide form -// definition. An organization-scoped change to a form's anonymous intake -// would therefore be shown in the editor and never reach a door; the save -// door refuses it instead, naming the env-wide save as the remedy. Pinned: +// On a walled posture the anonymous form doors read the env-wide form +// definition, so an organization-scoped change to a form's anonymous intake is +// refused at the save door, naming the env-wide save as the remedy. Pinned: // // - the organization-scoped withdrawal answers `403 NOT_OVERRIDABLE` and // nothing is saved (the organization still reads the published form, and From b5e4b515a72106c11841e39383effd2fee837c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:37:05 +0000 Subject: [PATCH 10/10] fix: judge the anonymous intake refusal only where a tenancy posture exists Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../protocol.org-scoped-write-refused.test.ts | 16 +++------------- packages/metadata-protocol/src/protocol.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 2a0f1211ed0..c0029686071 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -579,11 +579,10 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se config: { sharing: sharing(allowAnonymous) }, }); - /** `defaultOrgId` answers what the anonymous doors resolve; `undefined` = no tenancy service. */ - function makeTenancyProtocol(defaultOrgId: string | null | undefined) { + /** `defaultOrgId` answers what the anonymous doors resolve. */ + function makeTenancyProtocol(defaultOrgId: string | null) { const { engine, rows } = makeStubEngine(); - const services = new Map(); - if (defaultOrgId !== undefined) services.set('tenancy', { defaultOrgId: async () => defaultOrgId }); + const services = new Map([['tenancy', { defaultOrgId: async () => defaultOrgId }]]); const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any; return { protocol, rows }; } @@ -638,15 +637,6 @@ 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([]); }); - it('no tenancy service: the doors read env-wide, so the org-scoped withdrawal is refused', async () => { - const { protocol } = makeTenancyProtocol(undefined); - await publishEnvWide(protocol); - - await expect(protocol.saveMetaItem({ - type: 'view', name: 'task.intake_form', item: FORM_VIEW(false), organizationId: 'org_a', - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - }); - it('control (walled): an org-scoped edit that leaves the anonymous intake alone still saves', async () => { const { protocol, rows } = makeTenancyProtocol(null); await publishEnvWide(protocol); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d2dfe62fa5..f24bd495380 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -14528,9 +14528,10 @@ export class ObjectStackProtocolImplementation implements * 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`; so does a composition with no tenancy service), 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. + * `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 @@ -14549,9 +14550,8 @@ export class ObjectStackProtocolImplementation implements const tenancy = this.getServicesRegistry?.().get('tenancy') as | { defaultOrgId?: () => Promise } | undefined; - const doorOrganization = typeof tenancy?.defaultOrgId === 'function' - ? await tenancy.defaultOrgId() - : null; + 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(