diff --git a/.changeset/21476-walled-public-form-intake-unavailable.md b/.changeset/21476-walled-public-form-intake-unavailable.md new file mode 100644 index 00000000000..b973699b4ef --- /dev/null +++ b/.changeset/21476-walled-public-form-intake-unavailable.md @@ -0,0 +1,7 @@ +--- +'@objectstack/rest': patch +--- + +Public forms on a walled tenancy posture: a form whose object is walled by an organization column is no longer offered to anonymous visitors. An anonymous submission carries no organization, and on a walled posture an insert into such an object without one is refused, so the form used to render and then answer `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on every submit. Both anonymous form endpoints (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer it exactly as they answer a withdrawn form (`404 FORM_NOT_FOUND`), so an anonymous caller learns nothing about the deployment's tenancy. The administrator's read of the form (`GET /meta/view/:name`) states why in `_diagnostics.warnings`, located at the form's `sharing`, with the remedy: if the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it. Forms bound to tenancy-disabled objects, and single-posture deployments, are unchanged. + +Clause-②: no 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 76b91c3d498..d0b24b84a44 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 @@ -162,6 +162,10 @@ describe('walled posture: withdrawing a public form from anonymous intake', () = const p = await probe(); expect([p.get, p.submit]).toEqual([200, 201]); expect(p.landed).toHaveLength(1); + // [#21476] The control of `showcase-public-form-walled-intake.dogfood.test.ts`: + // a tenancy-disabled object takes intake on a walled posture, so the + // administrator's read states no intake reason. + expect((await read())._diagnostics?.warnings).toBeUndefined(); }); it('withdrawn in an organization: refused 403 NOT_OVERRIDABLE naming the env-wide save, and nothing is saved', async () => { diff --git a/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts new file mode 100644 index 00000000000..cb40752e185 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21476] On a WALLED tenancy posture the showcase's public contact form is +// not offered, on a real boot. +// +// `showcase_inquiry.contact` publishes `/forms/contact-us`, and +// `showcase_inquiry` is walled by the injected `organization_id`. An anonymous +// submission carries no organization, and on a walled posture the engine +// refuses an insert without one into a walled object. Before this pin the form +// was served (`GET` 200) and every submit answered `500 +// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Pinned: +// +// - both anonymous doors answer the not-found answer a withdrawn form gets, +// byte for byte (measured against the same form withdrawn env-wide on the +// same boot), and no `showcase_inquiry` row lands; +// - the administrator's read of the form names why, at `config.sharing`. +// +// The control (a form bound to a `tenancy: { enabled: false }` object on the +// same walled posture is accepted, and its admin read carries no warning) is +// `public-form-withdrawal-walled.dogfood.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +const VIEW = '/meta/view/showcase_inquiry.contact'; +const SYS = { isSystem: true } as const; + +describe('showcase, walled posture: the public contact form is not offered, and the admin read says why', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let probeSeq = 0; + + /** Both anonymous doors' raw answers, plus the rows a submit with a unique marker left. */ + const probe = async () => { + const marker = `walled_intake_probe_${++probeSeq}`; + const get = await stack.api('/forms/contact-us'); + 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 answers = [get.status, await get.text(), submit.status, await submit.text()]; + const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); + return { answers, landed: landed as unknown[] }; + }; + + /** The form as the administrator 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; + }; + + /** Save the form env-wide (the admin has no active organization) with `allowAnonymous` set. */ + const saveAllowAnonymous = async (published: Record, allowAnonymous: boolean) => { + const body = structuredClone(published); + body.config.sharing.allowAnonymous = allowAnonymous; + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + expect(res.status, await res.clone().text()).toBe(200); + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + multiTenant: 'posture-only', + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + }); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: a walled posture in force, no organization for an anonymous request, the form published', async () => { + const tenancy = stack.tenancy(); + expect(tenancy.posture).toBe('isolated'); + expect(await tenancy.defaultOrgId()).toBeNull(); + expect((await read()).config?.sharing).toMatchObject({ enabled: true, allowAnonymous: true }); + }); + + it('both doors answer the withdrawn form\'s not-found answer byte for byte, and nothing lands', async () => { + const unavailable = await probe(); + expect(unavailable.answers[0]).toBe(404); + expect(JSON.parse(unavailable.answers[1] as string).code).toBe('FORM_NOT_FOUND'); + expect(unavailable.landed).toHaveLength(0); + + const published = Object.fromEntries(Object.entries(await read()).filter(([k]) => !k.startsWith('_'))); + await saveAllowAnonymous(published, false); + try { + const withdrawn = await probe(); + expect(withdrawn.landed).toHaveLength(0); + expect(unavailable.answers).toEqual(withdrawn.answers); + } finally { + await saveAllowAnonymous(published, true); + } + // Republished, it is still not offered on this posture. + const again = await probe(); + expect(again.answers).toEqual(unavailable.answers); + expect(again.landed).toHaveLength(0); + }); + + it('the administrator\'s read names why, located at the form\'s sharing', async () => { + const warnings = ((await read())._diagnostics?.warnings ?? []) as Array<{ path: string; message: string }>; + expect(warnings).toHaveLength(1); + expect(warnings[0].path).toBe('config.sharing'); + for (const named of ['/forms/contact-us', "'showcase_inquiry'", "'organization_id'", "'isolated'"]) { + expect(warnings[0].message).toContain(named); + } + }); +}); diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts new file mode 100644 index 00000000000..8ab4cb88b41 --- /dev/null +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21476] One predicate decides whether an open public form can take an +// anonymous submission on this deployment, and every door that serves the form +// reads it. On a walled posture a form bound to an object walled by an +// organization column used to be served and then answer `500 +// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on every submit. Pinned: both doors +// answer the withdrawn form's answer byte for byte and nothing is written; every +// `/forms/` route is one of those doors; the controls are accepted; and the +// administrator's read (both arms) names the reason, inside the validator. + +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'; +const SLUG = 'contact-us'; + +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, headers: {} as Record }; + 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((k: string, v: string) => { res.headers[k] = v; return res; }); + res.send = vi.fn(() => res); + res.end = vi.fn(() => res); + return res; +} + +/** A flattened `viewKind: 'form'` item, as the protocol serves it. */ +const formView = (allowAnonymous = true) => ({ + name: 'contact', object: 'inquiry', viewKind: 'form', _diagnostics: { valid: true }, + config: { + data: { object: 'inquiry' }, + sections: [{ fields: ['name', 'email'] }], + sharing: { enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` }, + }, +}); + +/** The bound object as the doors read it: the registry injects `organization_id`. */ +const inquiryObject = (tenancyDisabled: boolean) => ({ + name: 'inquiry', label: 'Inquiry', ...(tenancyDisabled ? { tenancy: { enabled: false } } : {}), + fields: { + organization_id: { type: 'lookup', reference: 'sys_organization' }, + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + }, +}); + +/** Reproduces the registry's own "never registered" rejection. */ +const notRegistered = (): Error => Object.assign(new Error("Service 'tenancy' not found"), { + __objectstackServiceNotRegistered: true, code: 'SERVICE_NOT_REGISTERED', serviceName: 'tenancy', +}); + +type Tenancy = 'isolated' | 'group' | 'degraded' | 'single' | 'not-registered'; + +/** `tenancyDisabled`: the control object (ADR-0066); `allowAnonymous: false`: the withdrawn reference; `cached`: the default admin-read arm. */ +interface Setup { tenancy: Tenancy; tenancyDisabled?: boolean; allowAnonymous?: boolean; cached?: boolean } + +function build(setup: Setup) { + const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); + const getMetaItems = vi.fn(async (req: { type: string }) => { + if (req.type === 'view') return [formView(setup.allowAnonymous ?? true)]; + if (req.type === 'object') return [inquiryObject(setup.tenancyDisabled ?? false)]; + return []; + }); + const getMetaItemCached = vi.fn(async (_req: { cacheRequest: { ifNoneMatch?: string } }) => ({ + data: formView(setup.allowAnonymous ?? true), etag: { value: 'v1', weak: false }, notModified: false, + })); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems, + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: formView(setup.allowAnonymous ?? true) })), + getMetaItemCached: setup.cached ? getMetaItemCached : undefined, + createData, + }; + const tenancyServiceProvider = async () => { + switch (setup.tenancy) { + case 'isolated': return { posture: 'isolated', requestedPosture: 'isolated', defaultOrgId: async () => null }; + case 'group': return { posture: 'group', requestedPosture: 'group', defaultOrgId: async () => null }; + // A walled request the deployment cannot enforce: in force it is `single`. + case 'degraded': return { posture: 'single', requestedPosture: 'isolated', defaultOrgId: async () => null }; + case 'single': return { posture: 'single', requestedPosture: 'single', defaultOrgId: async () => ORG }; + case 'not-registered': throw notRegistered(); + } + }; + 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 as any).resolveExecCtx = async () => ({ userId: 'admin', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + const routes = rest.getRoutes(); + const find = (method: string, path: string) => routes.find((r) => r.method === method && r.path === path)!; + const formDoors = routes.filter((r) => r.path.includes('/forms/')); + const drive = async (route: { handler: (req: any, res: any) => any }, method: string) => { + const res = mockRes(); + const body = method === 'POST' ? { body: { name: 'x', email: 'x@example.com' } } : {}; + await route.handler({ params: { slug: SLUG }, query: {}, headers: {}, ...body } as any, res); + return res; + }; + return { + createData, getMetaItems, getMetaItemCached, formDoors, drive, + get: () => drive(find('GET', '/api/v1/forms/:slug'), 'GET'), + post: () => drive(find('POST', '/api/v1/forms/:slug/submit'), 'POST'), + async adminRead(headers: Record = {}) { + const res = mockRes(); + await find('GET', '/api/v1/meta/:type/:name').handler({ params: { type: 'view', name: 'contact' }, query: {}, headers } as any, res); + return res; + }, + }; +} + +/** What the withdrawn form answers on a door — the shape an unavailable form must match byte for byte. */ +async function withdrawnAnswer(door: 'get' | 'post'): Promise<[number, string]> { + const s = build({ tenancy: 'isolated', allowAnonymous: false }); + const res = await s[door](); + return [res.statusCode, JSON.stringify(res.body)]; +} + +const answer = (res: any): [number, string] => [res.statusCode, JSON.stringify(res.body)]; + +describe('[#21476] a public form that cannot take intake on this posture is not offered', () => { + it('REFERENCE: the withdrawn form answers 404 FORM_NOT_FOUND on both doors', async () => { + const [getStatus, getBody] = await withdrawnAnswer('get'); + const [postStatus, postBody] = await withdrawnAnswer('post'); + expect([getStatus, JSON.parse(getBody).code]).toEqual([404, 'FORM_NOT_FOUND']); + expect([postStatus, JSON.parse(postBody).code]).toEqual([404, 'FORM_NOT_FOUND']); + }); + + // ENUMERATION: the doors are read off the registered routes, not listed by + // hand, and each door × walled posture is its own row. + const doors = build({ tenancy: 'isolated' }).formDoors; + it('ENUMERATION: the routes under /forms/ are exactly the two anonymous form doors', () => { + expect(doors.map((r) => `${r.method} ${r.path}`).sort()) + .toEqual(['GET /api/v1/forms/:slug', 'POST /api/v1/forms/:slug/submit']); + }); + for (const door of doors) { + for (const posture of ['isolated', 'group'] as const) { + it(`${door.method} ${door.path} · '${posture}', walled object: the withdrawn form's answer byte for byte, nothing written`, async () => { + const s = build({ tenancy: posture }); + const route = s.formDoors.find((r) => r.method === door.method && r.path === door.path)!; + const expected = await withdrawnAnswer(door.method === 'POST' ? 'post' : 'get'); + expect(answer(await s.drive(route, door.method))).toEqual(expected); + expect(s.createData).not.toHaveBeenCalled(); + }); + } + } + + it('CONTROL: walled posture, object declared tenancy: { enabled: false } — accepted on both doors', async () => { + const s = build({ tenancy: 'isolated', tenancyDisabled: true }); + const get = await s.get(); + expect(get.statusCode).toBe(200); + expect(get.body.object).toBe('inquiry'); + expect((await s.post()).statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: single posture, walled object — accepted, and the object is not even read for the predicate', async () => { + const s = build({ tenancy: 'single' }); + expect((await s.post()).statusCode).toBe(201); + expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view']); + }); + + it('CONTROL: a degraded walled request reads the posture IN FORCE (single) — accepted', async () => { + const s = build({ tenancy: 'degraded' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + it('CONTROL: no tenancy service registered — no wall the doors can read, accepted', async () => { + const s = build({ tenancy: 'not-registered' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); +}); + +describe('[#21476] the administrator\'s read names why intake is unavailable', () => { + for (const cached of [false, true]) { + const arm = cached ? 'cached arm' : 'uncached arm'; + + it(`${arm}: walled posture, walled object — a warning located at the form's sharing, naming the reason`, async () => { + const s = build({ tenancy: 'isolated', cached }); + const res = await s.adminRead(); + expect(res.statusCode).toBe(200); + const diagnostics = res.body.item._diagnostics; + expect(diagnostics.valid).toBe(true); + expect(diagnostics.warnings).toHaveLength(1); + expect(diagnostics.warnings[0].path).toBe('config.sharing'); + const message: string = diagnostics.warnings[0].message; + for (const named of [`/forms/${SLUG}`, "'inquiry'", "'organization_id'", "'isolated'", 'tenancy: { enabled: false }']) { + expect(message).toContain(named); + } + }); + + it(`${arm}: CONTROL — tenancy-disabled object or single posture, no warning and _diagnostics untouched`, async () => { + for (const setup of [{ tenancy: 'isolated', tenancyDisabled: true }, { tenancy: 'single' }] as const) { + const res = await build({ ...setup, cached }).adminRead(); + expect(res.statusCode).toBe(200); + expect(res.body.item._diagnostics).toEqual({ valid: true }); + } + }); + } + + it('cached arm: the reason enters the validator — the bare protocol ETag revalidates into the reason, the folded one is 304', async () => { + const s = build({ tenancy: 'isolated', cached: true }); + const first = await s.adminRead(); + const etag = first.headers.ETag; + expect(etag).toMatch(/^"v1~[0-9a-f]{8}"$/); + expect(s.getMetaItemCached).toHaveBeenCalledTimes(1); + expect(s.getMetaItemCached.mock.calls[0]?.[0].cacheRequest).toEqual({ ifNoneMatch: undefined, ifModifiedSince: undefined }); + + const stale = await s.adminRead({ 'if-none-match': '"v1"' }); + expect(stale.statusCode).toBe(200); + expect(stale.body.item._diagnostics.warnings).toHaveLength(1); + + const fresh = await s.adminRead({ 'if-none-match': etag }); + expect(fresh.statusCode).toBe(304); + }); + + it('cached arm: CONTROL — with no reason the validator is the protocol\'s own, and it still answers 304', async () => { + const s = build({ tenancy: 'isolated', tenancyDisabled: true, cached: true }); + const first = await s.adminRead(); + expect(first.headers.ETag).toBe('"v1"'); + expect((await s.adminRead({ 'if-none-match': '"v1"' })).statusCode).toBe(304); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b1dacf000d4..239ac2d6d39 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -74,6 +74,10 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, + type AnonymousFormIntakeCandidate, + // [#21476] The wall column, and the ADR-0106 fingerprint, for the intake-availability predicate. + resolveRecordWallOrganizationField, + objectFieldVisibilityFingerprint, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -161,7 +165,12 @@ import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; // single-item read path rebuilds its body through, from the spec's own storage // predicates — so the signal the grid reads cannot drift from what the runtime // doors (#6994/#7095) refuse. -import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import { + PUBLIC_FORM_SERVER_MANAGED_FIELDS, + normalizeTenancyPosture, + postureEnforcesWall, + type TenancyPosture, +} from '@objectstack/spec/security'; import { PLURAL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -1750,6 +1759,113 @@ type MetaReadVerdict = | { kind: 'serve'; document: any } | { kind: 'refuse'; send: (res: any) => void }; +/** [#21476] Why an open public form cannot take an anonymous submission on this deployment. */ +interface AnonymousFormIntakeUnavailable { + /** The object the form submits into, the walled posture in force, and the column it is walled by. */ + object: string; + posture: TenancyPosture; + tenantField: string; +} + +/** + * [#21476] THE intake-availability predicate: `null` when an open public form + * can take an anonymous submission here, otherwise why it cannot. + * + * An anonymous submission carries no organization, and on a walled posture the + * engine refuses an insert without one into an object walled by an organization + * column (`resolveSystemInsertOrganization`, `@objectstack/objectql`). Such a + * form used to be served and then answer `500` on every submit; now both doors + * answer it as a withdrawn form and the admin read says why. Its two facts: + * + * - `posture`: the tenancy service's IN-FORCE posture, the value SecurityPlugin + * hands the engine (`setTenancyPostureProvider`), never re-read from env. A + * degraded walled request is `single` there, and the engine then derives the + * install's organization. `undefined` (no tenancy service) names no wall. + * - the wall column: `resolveRecordWallOrganizationField` + * (`@objectstack/metadata-core`), over the served object schema, which + * carries the injected `organization_id`. `readObjectSchema` runs only once + * a wall is in force. + * + * ⚠️ It reads declarations. The engine also passes a federated (`external`) + * object, a platform object its inventory has not admitted, and a row a + * `beforeInsert` hook stamped; a form bound to one of those with a wall column + * is withheld here although the engine would accept it (fail closed). + */ +async function anonymousFormIntakeUnavailability( + object: string, + posture: TenancyPosture | undefined, + readObjectSchema: () => Promise, +): Promise { + if (posture === undefined || !postureEnforcesWall(posture)) return null; + const objectSchema = await readObjectSchema(); + const fields = (objectSchema as { fields?: unknown } | null | undefined)?.fields; + const tenantField = resolveRecordWallOrganizationField( + objectSchema, + (field) => !!fields && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, field), + ); + return tenantField === null ? null : { object, posture, tenantField }; +} + +/** [#21476] The posture in force, as the tenancy service an anonymous form request reads reports it. */ +function anonymousFormTenancyPosture(tenancy: unknown): TenancyPosture | undefined { + return normalizeTenancyPosture((tenancy as { posture?: unknown } | undefined)?.posture); +} + +/** [#21331] The organization an anonymous form request reads the form in (`defaultOrgId()`). */ +async function anonymousFormOrganization(tenancy: any): Promise { + if (!tenancy || typeof tenancy.defaultOrgId !== 'function') return undefined; + const organizationId = await tenancy.defaultOrgId(); + return typeof organizationId === 'string' && organizationId ? organizationId : undefined; +} + +/** The object an open form candidate submits into, read the one way the doors and the admin read share. */ +function anonymousFormObjectName(view: any, form: any): string | undefined { + return form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object; +} + +/** [#21476] Where a candidate's `sharing` sits in the served `view` body: the location the admin read names. */ +function anonymousFormSharingPath(view: Record, candidate: AnonymousFormIntakeCandidate): string { + if (candidate.form === view.form) return 'form.sharing'; + if (candidate.key !== undefined && view.formViews?.[candidate.key] === candidate.form) { + return `formViews.${candidate.key}.sharing`; + } + return 'config.sharing'; +} + +/** [#21476] The reason the admin read states, located at the form's `sharing`. */ +function anonymousFormIntakeUnavailableMessage(slug: string, u: AnonymousFormIntakeUnavailable): string { + return ( + `Public form '/forms/${slug}' is not offered to anonymous visitors on this deployment, so both ` + + `anonymous form doors answer it as not found. It submits into '${u.object}', which is walled by ` + + `'${u.tenantField}', and this deployment runs the '${u.posture}' tenancy posture: an anonymous ` + + `submission carries no organization, and an insert without one into a walled object is refused. ` + + `If the rows of '${u.object}' belong to no organization, declare that on the object ` + + `(tenancy: { enabled: false }) and the form is offered again. Otherwise collect this data through ` + + `a signed-in surface.` + ); +} + +/** + * [#21476] Put the admin read's intake reasons in `_diagnostics.warnings`, where + * a derived view warning already goes (`stampRenameWarning`). A declared read + * decoration, so a GET then PUT round trip never stores it. + */ +function stampAnonymousFormIntakeWarnings( + document: any, + warnings: ReadonlyArray<{ path: string; message: string }>, +): any { + if (warnings.length === 0 || !document || typeof document !== 'object') return document; + const prior = document._diagnostics; + const diagnostics: Record = prior && typeof prior === 'object' ? { ...prior } : { valid: true }; + diagnostics.warnings = [...(Array.isArray(prior?.warnings) ? prior.warnings : []), ...warnings]; + return { ...document, _diagnostics: diagnostics }; +} + +/** [#21476] Those reasons' ETag dimension; empty when there is none (the ADR-0106 D3 fold). */ +function anonymousFormIntakeFingerprint(warnings: ReadonlyArray<{ path: string; message: string }>): string { + return objectFieldVisibilityFingerprint(warnings.map((w) => JSON.stringify([w.path, w.message]))); +} + /** * RestServer * @@ -3792,8 +3908,17 @@ export class RestServer { return { ...this.metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), requestLocale: (i18n) => this.extractLocale(req, i18n), - translateEnvelope: (envelope, document) => - this.translateMetaEnvelope(req, req.params.type, environmentId, envelope as Record, document), + // [#21476] The uncached arm's share of the public-form intake + // reason the cached arm states (`GET /meta/:type/:name`). + translateEnvelope: async (envelope, document) => + this.translateMetaEnvelope( + req, req.params.type, environmentId, envelope as Record, + RestServer.metaTypeSingular(req.params.type) === 'view' + ? stampAnonymousFormIntakeWarnings( + document, await this.anonymousFormIntakeWarnings(environmentId, req, p, document), + ) + : document, + ), }; } @@ -6588,8 +6713,15 @@ export class RestServer { // against the fingerprinted ETag, which is the one // that identifies what we are actually sending. const maskApplies = maskPosture.kind !== 'passthrough'; + // [#21476] Same move for a `view`: its body can carry + // the public-form intake reason, which derives from + // the posture and the bound object, and the + // protocol's validator hashes neither. With no reason + // the folded ETag is byte-identical, so a view's + // `304` answers exactly as before. + const intakeFolds = metaType === 'view'; const cacheRequest = { - ifNoneMatch: maskApplies ? undefined : (req.headers['if-none-match'] as string), + ifNoneMatch: (maskApplies || intakeFolds) ? undefined : (req.headers['if-none-match'] as string), ifModifiedSince: req.headers['if-modified-since'] as string, }; @@ -6647,6 +6779,14 @@ export class RestServer { cachedDocument = masked.document; visibilityFingerprint = masked.fingerprint; } + // [#21476] The administrator's read names why an open + // public form is not offered on this posture. + let intakeFingerprint = ''; + if (intakeFolds) { + const warnings = await this.anonymousFormIntakeWarnings(environmentId, req, p, cachedDocument); + cachedDocument = stampAnonymousFormIntakeWarnings(cachedDocument, warnings); + intakeFingerprint = anonymousFormIntakeFingerprint(warnings); + } // [ADR-0106 D6 tier 2] Visibility undetermined → // the body is unmasked, so it must not be stored or @@ -6673,12 +6813,15 @@ export class RestServer { // to the pre-ADR one. A cohort shares 304s; a // permission change moves the fingerprint and // self-invalidates the stale 304. - const value = foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint); + const value = foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), + intakeFingerprint, + ); const etagValue = result.etag.weak ? `W/"${value}"` : `"${value}"`; res.header('ETag', etagValue); - if (maskApplies && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { + if ((maskApplies || intakeFolds) && normalizeIfNoneMatch(req.headers['if-none-match']) === value) { res.status(304).send(); return; } @@ -10516,14 +10659,101 @@ export class RestServer { }); } + /** + * [#21331 · #21476] The `tenancy` service an anonymous form request reads, + * or `undefined` in the supported no-tenancy composition. Which + * organization it answers, and why it fails closed: the comment above + * `resolveFormBySlug` in {@link registerFormEndpoints}. + */ + private async resolveAnonymousFormTenancy(environmentId: string | undefined, req: any): Promise { + try { + const envId = environmentId === 'platform' + ? undefined + : await this.resolveRequestEnvironmentId(environmentId, req); + if (envId && this.kernelManager) { + const kernel: any = await this.kernelManager.getOrCreate(envId); + return typeof kernel?.getServiceAsync === 'function' + ? await kernel.getServiceAsync('tenancy') + : undefined; + } + if (this.tenancyServiceProvider) return await this.tenancyServiceProvider(environmentId); + return undefined; + } catch (err) { + if (isServiceNotRegisteredError(err)) return undefined; + throw new AuthzStoreUnavailableError('tenancy', err); + } + } + + /** + * The object schemas an anonymous form request reads, in the organization + * the form itself was resolved in (#21331). They carry the columns the + * registry injects, `organization_id` among them. + */ + private async readFormObjectDefinitions( + p: RestProtocol, + environmentId: string | undefined, + organizationId: string | undefined, + ): Promise { + const objectsRequest: TransportScopedMetaRequest = { + type: 'object', + ...(environmentId ? { environmentId } : {}), + ...(organizationId ? { organizationId } : {}), + }; + const r: any = await p.getMetaItems(objectsRequest); + return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; + } + + /** + * [#21476] The administrator's read of a `view`: one warning per open + * public form that cannot take intake on this deployment, located at that + * form's `sharing` and naming why. Asked through the SAME predicate, the + * same tenancy read and the same object read as both anonymous doors + * (`registerFormEndpoints`), so the reason is shown exactly when the doors + * answer not-found. A view with no open public form reads nothing. + */ + private async anonymousFormIntakeWarnings( + environmentId: string | undefined, + req: any, + p: RestProtocol, + view: unknown, + ): Promise> { + if (!view || typeof view !== 'object' || typeof (p as any).getMetaItems !== 'function') return []; + const candidates = anonymousFormIntakeCandidates(view); + if (candidates.length === 0) return []; + const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); + const posture = anonymousFormTenancyPosture(tenancy); + let objects: Promise | undefined; + const readObjects = (): Promise => (objects ??= anonymousFormOrganization(tenancy) + .then((organizationId) => this.readFormObjectDefinitions(p, environmentId, organizationId))); + const warnings: Array<{ path: string; message: string }> = []; + for (const candidate of candidates) { + const object = anonymousFormObjectName(view, candidate.form); + if (!object) continue; + const unavailable = await anonymousFormIntakeUnavailability( + object, + posture, + async () => (await readObjects()).find((o: any) => o?.name === object), + ); + if (!unavailable) continue; + warnings.push({ + path: anonymousFormSharingPath(view as Record, candidate), + message: anonymousFormIntakeUnavailableMessage(candidate.slug, unavailable), + }); + } + return warnings; + } + /** * Register public (anonymous) form endpoints. * * Public forms are opt-in: a `FormView` becomes accessible to anonymous * visitors only when `sharing.enabled === true`, `sharing.allowAnonymous * === true` AND a `sharing.publicLink` slug is configured - * (`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`). Two - * routes are registered: + * (`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`). A form + * whose bound object cannot take an anonymous submission on this + * deployment's posture is not offered either + * ({@link anonymousFormIntakeUnavailability}): both routes answer it exactly + * as they answer a withdrawn form. Two routes are registered: * * GET {basePath}/forms/:slug → resolved form spec * POST {basePath}/forms/:slug/submit → INSERT record (no auth required) @@ -10559,11 +10789,7 @@ export class RestServer { if (!view || typeof view !== 'object') continue; for (const c of anonymousFormIntakeCandidates(view)) { if (c.slug !== slug) continue; - const objectName = - c.form?.data?.object ?? - view?.list?.data?.object ?? - view?.form?.data?.object ?? - view?.object; + const objectName = anonymousFormObjectName(view, c.form); if (!objectName) continue; return { view, form: c.form, object: objectName }; } @@ -10591,7 +10817,8 @@ export class RestServer { // // 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". + // own copy of "is this form public" — nor, since #21476, of "can it + // take intake on this posture", which the same tenancy read answers. // // Fails CLOSED. A tenancy service that is registered but cannot be // reached raises `AuthzStoreUnavailableError`, the classification @@ -10601,32 +10828,6 @@ export class RestServer { // 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, @@ -10634,7 +10835,8 @@ export class RestServer { ): 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 tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); + const organizationId = await anonymousFormOrganization(tenancy); const viewsRequest: TransportScopedMetaRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), @@ -10647,7 +10849,17 @@ export class RestServer { ? result : []; const match = findPublicFormView(items, slug); - return match ? { ...match, organizationId } : null; + if (!match) return null; + // [#21476] A form that cannot take intake on this posture is not + // offered: `null` here IS the withdrawn form's answer on both + // doors, so an anonymous caller learns nothing about the tenancy. + const unavailable = await anonymousFormIntakeUnavailability( + match.object, + anonymousFormTenancyPosture(tenancy), + async () => (await this.readFormObjectDefinitions(p, environmentId, organizationId)) + .find((o: any) => o?.name === match.object), + ); + return unavailable ? null : { ...match, organizationId }; }; // GET /forms/:slug — resolve and return the public form spec