From ccf4a0ae34e5311a9b4d9456a401778787990f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:06:29 +0000 Subject: [PATCH 1/6] wip(rest): one intake-availability predicate for both anonymous form doors and the admin read Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 379 +++++++++++++++++++++++++------ 1 file changed, 308 insertions(+), 71 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b1dacf000d4..a38abf20800 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -74,6 +74,14 @@ 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] "Which column is this object WALLED by?" — the wall-side + // tenant-column answer, read by the one intake-availability predicate + // (`anonymousFormIntakeUnavailability`, below) rather than re-spelled here. + resolveRecordWallOrganizationField, + // [#21476] The same order-independent FNV-1a fingerprint the ADR-0106 fold + // uses, so the admin read's intake reason enters the validator one way. + objectFieldVisibilityFingerprint, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -161,7 +169,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 +1763,141 @@ type MetaReadVerdict = | { kind: 'serve'; document: any } | { kind: 'refuse'; send: (res: any) => void }; +// ───────────────────────────────────────────────────────────────────────────── +// [#21476] Can an open public form take an anonymous submission on THIS +// deployment? One predicate, asked by both anonymous form doors and by the +// administrator's read of the form. +// ───────────────────────────────────────────────────────────────────────────── + +/** Why an open public form cannot take an anonymous submission on this deployment. */ +interface AnonymousFormIntakeUnavailable { + /** The object the form submits into. */ + object: string; + /** The walled posture in force (`group` or `isolated`). */ + posture: TenancyPosture; + /** The column the object is walled by. */ + 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 is a write that carries no organization. The submit + * door threads none, and the tenancy service's `defaultOrgId()` answers `null` + * under every walled request. On a walled posture the engine refuses an insert + * without an organization into an object walled by an organization column + * (`resolveSystemInsertOrganization`, `@objectstack/objectql`), so such a form + * was served on `GET` and then answered `500` on every submit. Here it is not + * offered at all: both doors answer the not-found shape a withdrawn form gets, + * and the administrator's read says why (triage ruling on #21476). + * + * The two facts it reads, neither restated here: + * + * - `posture` is the tenancy service's IN-FORCE `posture`, the value + * SecurityPlugin hands the engine (`setTenancyPostureProvider`). A degraded + * walled request reads `single` there, and the engine then derives the + * install's organization, so intake is available. `undefined` (no tenancy + * service) names no wall the doors can read. + * - The wall column comes from `resolveRecordWallOrganizationField` + * (`@objectstack/metadata-core`), the wall-side twin of the engine's + * `resolveTenantFieldName`, over the object schema the doors serve, which + * carries the injected `organization_id`. + * + * `readObjectSchema` is called only when a wall is in force, so a single-posture + * deployment pays no read for it. + * + * ⚠️ Boundary: it reads declarations. The engine also passes, without an + * organization, a federated (`external`) object and a platform-namespace object + * its inventory has not admitted, and it accepts a row a `beforeInsert` hook + * stamped. A form bound to one of those that also carries a wall column is + * withheld here although the engine would accept it, which is the fail-closed + * direction. + */ +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: the + * tenancy service's `defaultOrgId()`, or `undefined` when there is none. + */ +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 into the served document's + * `_diagnostics.warnings`, the read decoration a second producer already uses + * for a derived view warning (`stampRenameWarning`, `@objectstack/spec/ui`). + * `_diagnostics` is a declared read decoration, so a GET then PUT round trip + * strips it and nothing reaches storage. + */ +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] The ETag dimension of those reasons: they derive from the posture + * and the bound object, which the protocol's validator never hashes. Empty when + * there is none, so the validator is then byte-identical (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 +3940,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 +6745,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 +6811,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 +6845,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 +10691,122 @@ export class RestServer { }); } + /** + * [#21331 · #21476] The `tenancy` service an anonymous form request reads, + * or `undefined` in the supported no-tenancy composition. It answers both + * facts the doors need: WHICH organization's metadata the request reads + * ({@link anonymousFormOrganization}) and the posture in force + * ({@link anonymousFormTenancyPosture}), which the intake-availability + * predicate 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 + * answer is `defaultOrgId()`: the organization a single-posture deployment + * binds every principal to, the one the administrator's own session is in + * and the one the engine stamps on the row the submit inserts. It is + * `undefined` before any organization exists, and on a walled posture, + * where the env-wide state governs. + * + * Fails CLOSED. A tenancy service that is registered but cannot be reached + * raises `AuthzStoreUnavailableError`, the classification + * `classifyAdmissionTenancyPosture` applies to the same seam, and the door + * 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. + */ + 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 +10842,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 }; } @@ -10571,62 +10850,9 @@ 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; - }; - + // Asked ONCE per request, here. Every door below reads the form + // through this one resolution, so no door keeps its own copy of "is + // this form public" or of "can it take intake here" (#21476). const resolveFormBySlug = async ( environmentId: string | undefined, req: any, @@ -10634,7 +10860,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 +10874,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 From 3d245b65598083b559e5a968e9b29108cdaa3c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:08:44 +0000 Subject: [PATCH 2/6] test(rest): pin the intake-availability predicate on both doors and the admin read Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../public-form-intake-availability.test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 packages/rest/src/public-form-intake-availability.test.ts 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..53d9031a17d --- /dev/null +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -0,0 +1,286 @@ +// 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. +// +// An anonymous submission carries no organization. On a walled tenancy posture +// the engine refuses an insert without one into an object walled by an +// organization column, so a form bound to such an object used to be served +// (`GET` 200) and then refuse every submit (`500 +// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`). Pinned here: +// +// - walled posture, walled object: both anonymous doors answer the withdrawn +// form's not-found answer, byte for byte, and nothing is written; +// - every route under `/forms/` is one of those doors (the enumeration), so a +// third door cannot be added without reading the predicate; +// - controls: the same form bound to a `tenancy: { enabled: false }` object, +// the single posture, a degraded walled request (in force: `single`), and no +// tenancy service at all are all accepted; +// - the administrator's read (`GET /meta/view/:name`, both arms) names the +// reason at the form's `sharing`, exactly when the doors answer not-found, +// and the reason enters the cached arm's 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. */ +function formView(allowAnonymous = true) { + return { + name: 'contact', + object: 'inquiry', + viewKind: 'form', + config: { + data: { object: 'inquiry' }, + sections: [{ fields: ['name', 'email'] }], + sharing: { enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` }, + }, + _diagnostics: { valid: true }, + }; +} + +/** The bound object as the doors read it: the registry injects `organization_id`. */ +function inquiryObject(tenancyDisabled: boolean) { + return { + 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. */ +function notRegistered(): Error { + return Object.assign(new Error("Service 'tenancy' not found"), { + __objectstackServiceNotRegistered: true, + code: 'SERVICE_NOT_REGISTERED', + serviceName: 'tenancy', + }); +} + +type Tenancy = 'isolated' | 'group' | 'degraded' | 'single' | 'not-registered'; + +interface Setup { + tenancy: Tenancy; + /** The bound object opts out of tenancy (ADR-0066) — the control. */ + tenancyDisabled?: boolean; + /** `false` withdraws the form, the reference answer. */ + allowAnonymous?: boolean; + /** Serve the admin read from `getMetaItemCached` (the default arm) instead of `getMetaItem`. */ + 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 () => ({ + 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(); + await route.handler({ + params: { slug: SLUG }, + query: {}, + headers: {}, + ...(method === 'POST' ? { body: { name: 'x', email: 'x@example.com' } } : {}), + } 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']); + }); + + for (const posture of ['isolated', 'group'] as const) { + it(`walled ('${posture}'), walled object: both doors answer the withdrawn form's answer byte for byte, nothing is written`, async () => { + const s = build({ tenancy: posture }); + expect(answer(await s.get())).toEqual(await withdrawnAnswer('get')); + expect(answer(await s.post())).toEqual(await withdrawnAnswer('post')); + expect(s.createData).not.toHaveBeenCalled(); + }); + } + + it('ENUMERATION: every route under /forms/ is an anonymous form door, and each answers the withdrawn answer', async () => { + const s = build({ tenancy: 'isolated' }); + expect(s.formDoors.map((r) => `${r.method} ${r.path}`).sort()).toEqual([ + 'GET /api/v1/forms/:slug', + 'POST /api/v1/forms/:slug/submit', + ]); + for (const door of s.formDoors) { + const expected = await withdrawnAnswer(door.method === 'POST' ? 'post' : 'get'); + expect(answer(await s.drive(door, door.method)), `${door.method} ${door.path}`).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.mock.calls[0][0].cacheRequest.ifNoneMatch).toBeUndefined(); + + 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); + }); +}); From 071efc252ad560f9d385acc6523655d4e978b349 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:10:00 +0000 Subject: [PATCH 3/6] test(dogfood): the walled showcase does not offer its public contact form, and the admin read says why Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...lic-form-withdrawal-walled.dogfood.test.ts | 4 + ...-public-form-walled-intake.dogfood.test.ts | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts 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); + } + }); +}); From 657f71d52be18c2ed8a792a979f8950456ee9c8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:10:23 +0000 Subject: [PATCH 4/6] chore(changeset): rest patch for the walled public-form intake answer Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21476-walled-public-form-intake-unavailable.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/21476-walled-public-form-intake-unavailable.md 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 From 0f04e40b1d323b9cc9ab72b492da4c62e617f8d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:14:14 +0000 Subject: [PATCH 5/6] refactor(rest): keep the doors' tenancy comment in place and tighten the intake predicate's docs Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../public-form-intake-availability.test.ts | 114 +++++--------- packages/rest/src/rest-server.ts | 143 ++++++++---------- 2 files changed, 94 insertions(+), 163 deletions(-) diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index 53d9031a17d..4bc4a887cae 100644 --- a/packages/rest/src/public-form-intake-availability.test.ts +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -2,24 +2,12 @@ // // [#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. -// -// An anonymous submission carries no organization. On a walled tenancy posture -// the engine refuses an insert without one into an object walled by an -// organization column, so a form bound to such an object used to be served -// (`GET` 200) and then refuse every submit (`500 -// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`). Pinned here: -// -// - walled posture, walled object: both anonymous doors answer the withdrawn -// form's not-found answer, byte for byte, and nothing is written; -// - every route under `/forms/` is one of those doors (the enumeration), so a -// third door cannot be added without reading the predicate; -// - controls: the same form bound to a `tenancy: { enabled: false }` object, -// the single posture, a degraded walled request (in force: `single`), and no -// tenancy service at all are all accepted; -// - the administrator's read (`GET /meta/view/:name`, both arms) names the -// reason at the form's `sharing`, exactly when the doors answer not-found, -// and the reason enters the cached arm's validator. +// 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'; @@ -49,54 +37,34 @@ function mockRes() { } /** A flattened `viewKind: 'form'` item, as the protocol serves it. */ -function formView(allowAnonymous = true) { - return { - name: 'contact', - object: 'inquiry', - viewKind: 'form', - config: { - data: { object: 'inquiry' }, - sections: [{ fields: ['name', 'email'] }], - sharing: { enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` }, - }, - _diagnostics: { valid: true }, - }; -} +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`. */ -function inquiryObject(tenancyDisabled: boolean) { - return { - 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' }, - }, - }; -} +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. */ -function notRegistered(): Error { - return Object.assign(new Error("Service 'tenancy' not found"), { - __objectstackServiceNotRegistered: true, - code: 'SERVICE_NOT_REGISTERED', - serviceName: 'tenancy', - }); -} +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'; -interface Setup { - tenancy: Tenancy; - /** The bound object opts out of tenancy (ADR-0066) — the control. */ - tenancyDisabled?: boolean; - /** `false` withdraws the form, the reference answer. */ - allowAnonymous?: boolean; - /** Serve the admin read from `getMetaItemCached` (the default arm) instead of `getMetaItem`. */ - cached?: boolean; -} +/** `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: {} }); @@ -105,10 +73,8 @@ function build(setup: Setup) { if (req.type === 'object') return [inquiryObject(setup.tenancyDisabled ?? false)]; return []; }); - const getMetaItemCached = vi.fn(async () => ({ - data: formView(setup.allowAnonymous ?? true), - etag: { value: 'v1', weak: false }, - notModified: false, + 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: '' } }), @@ -141,28 +107,17 @@ function build(setup: Setup) { const formDoors = routes.filter((r) => r.path.includes('/forms/')); const drive = async (route: { handler: (req: any, res: any) => any }, method: string) => { const res = mockRes(); - await route.handler({ - params: { slug: SLUG }, - query: {}, - headers: {}, - ...(method === 'POST' ? { body: { name: 'x', email: 'x@example.com' } } : {}), - } as any, res); + 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, + 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, - ); + await find('GET', '/api/v1/meta/:type/:name').handler({ params: { type: 'view', name: 'contact' }, query: {}, headers } as any, res); return res; }, }; @@ -267,7 +222,8 @@ describe('[#21476] the administrator\'s read names why intake is unavailable', ( const first = await s.adminRead(); const etag = first.headers.ETag; expect(etag).toMatch(/^"v1~[0-9a-f]{8}"$/); - expect(s.getMetaItemCached.mock.calls[0][0].cacheRequest.ifNoneMatch).toBeUndefined(); + 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); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a38abf20800..239ac2d6d39 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -75,12 +75,8 @@ import { // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, type AnonymousFormIntakeCandidate, - // [#21476] "Which column is this object WALLED by?" — the wall-side - // tenant-column answer, read by the one intake-availability predicate - // (`anonymousFormIntakeUnavailability`, below) rather than re-spelled here. + // [#21476] The wall column, and the ADR-0106 fingerprint, for the intake-availability predicate. resolveRecordWallOrganizationField, - // [#21476] The same order-independent FNV-1a fingerprint the ADR-0106 fold - // uses, so the admin read's intake reason enters the validator one way. objectFieldVisibilityFingerprint, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; @@ -1763,19 +1759,11 @@ type MetaReadVerdict = | { kind: 'serve'; document: any } | { kind: 'refuse'; send: (res: any) => void }; -// ───────────────────────────────────────────────────────────────────────────── -// [#21476] Can an open public form take an anonymous submission on THIS -// deployment? One predicate, asked by both anonymous form doors and by the -// administrator's read of the form. -// ───────────────────────────────────────────────────────────────────────────── - -/** Why an open public form cannot take an anonymous submission on this deployment. */ +/** [#21476] Why an open public form cannot take an anonymous submission on this deployment. */ interface AnonymousFormIntakeUnavailable { - /** The object the form submits into. */ + /** The object the form submits into, the walled posture in force, and the column it is walled by. */ object: string; - /** The walled posture in force (`group` or `isolated`). */ posture: TenancyPosture; - /** The column the object is walled by. */ tenantField: string; } @@ -1783,36 +1771,25 @@ interface AnonymousFormIntakeUnavailable { * [#21476] THE intake-availability predicate: `null` when an open public form * can take an anonymous submission here, otherwise why it cannot. * - * An anonymous submission is a write that carries no organization. The submit - * door threads none, and the tenancy service's `defaultOrgId()` answers `null` - * under every walled request. On a walled posture the engine refuses an insert - * without an organization into an object walled by an organization column - * (`resolveSystemInsertOrganization`, `@objectstack/objectql`), so such a form - * was served on `GET` and then answered `500` on every submit. Here it is not - * offered at all: both doors answer the not-found shape a withdrawn form gets, - * and the administrator's read says why (triage ruling on #21476). - * - * The two facts it reads, neither restated here: - * - * - `posture` is the tenancy service's IN-FORCE `posture`, the value - * SecurityPlugin hands the engine (`setTenancyPostureProvider`). A degraded - * walled request reads `single` there, and the engine then derives the - * install's organization, so intake is available. `undefined` (no tenancy - * service) names no wall the doors can read. - * - The wall column comes from `resolveRecordWallOrganizationField` - * (`@objectstack/metadata-core`), the wall-side twin of the engine's - * `resolveTenantFieldName`, over the object schema the doors serve, which - * carries the injected `organization_id`. + * 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: * - * `readObjectSchema` is called only when a wall is in force, so a single-posture - * deployment pays no read for it. + * - `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. * - * ⚠️ Boundary: it reads declarations. The engine also passes, without an - * organization, a federated (`external`) object and a platform-namespace object - * its inventory has not admitted, and it accepts a row a `beforeInsert` hook - * stamped. A form bound to one of those that also carries a wall column is - * withheld here although the engine would accept it, which is the fail-closed - * direction. + * ⚠️ 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, @@ -1834,10 +1811,7 @@ function anonymousFormTenancyPosture(tenancy: unknown): TenancyPosture | undefin return normalizeTenancyPosture((tenancy as { posture?: unknown } | undefined)?.posture); } -/** - * [#21331] The organization an anonymous form request reads the form in: the - * tenancy service's `defaultOrgId()`, or `undefined` when there is none. - */ +/** [#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(); @@ -1872,11 +1846,9 @@ function anonymousFormIntakeUnavailableMessage(slug: string, u: AnonymousFormInt } /** - * [#21476] Put the admin read's intake reasons into the served document's - * `_diagnostics.warnings`, the read decoration a second producer already uses - * for a derived view warning (`stampRenameWarning`, `@objectstack/spec/ui`). - * `_diagnostics` is a declared read decoration, so a GET then PUT round trip - * strips it and nothing reaches storage. + * [#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, @@ -1889,11 +1861,7 @@ function stampAnonymousFormIntakeWarnings( return { ...document, _diagnostics: diagnostics }; } -/** - * [#21476] The ETag dimension of those reasons: they derive from the posture - * and the bound object, which the protocol's validator never hashes. Empty when - * there is none, so the validator is then byte-identical (the ADR-0106 D3 fold). - */ +/** [#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]))); } @@ -10693,30 +10661,9 @@ export class RestServer { /** * [#21331 · #21476] The `tenancy` service an anonymous form request reads, - * or `undefined` in the supported no-tenancy composition. It answers both - * facts the doors need: WHICH organization's metadata the request reads - * ({@link anonymousFormOrganization}) and the posture in force - * ({@link anonymousFormTenancyPosture}), which the intake-availability - * predicate 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 - * answer is `defaultOrgId()`: the organization a single-posture deployment - * binds every principal to, the one the administrator's own session is in - * and the one the engine stamps on the row the submit inserts. It is - * `undefined` before any organization exists, and on a walled posture, - * where the env-wide state governs. - * - * Fails CLOSED. A tenancy service that is registered but cannot be reached - * raises `AuthzStoreUnavailableError`, the classification - * `classifyAdmissionTenancyPosture` applies to the same seam, and the door - * 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. + * 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 { @@ -10850,9 +10797,37 @@ export class RestServer { return null; }; - // Asked ONCE per request, here. Every door below reads the form - // through this one resolution, so no door keeps its own copy of "is - // this form public" or of "can it take intake here" (#21476). + // [#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" — 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 + // `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 resolveFormBySlug = async ( environmentId: string | undefined, req: any, From 8a8839f9ab8e1fb175b863edd040c7afdfed8741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:15:22 +0000 Subject: [PATCH 6/6] test(rest): one enumerated row per anonymous form door and walled posture Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../public-form-intake-availability.test.ts | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/rest/src/public-form-intake-availability.test.ts b/packages/rest/src/public-form-intake-availability.test.ts index 4bc4a887cae..8ab4cb88b41 100644 --- a/packages/rest/src/public-form-intake-availability.test.ts +++ b/packages/rest/src/public-form-intake-availability.test.ts @@ -140,27 +140,24 @@ describe('[#21476] a public form that cannot take intake on this posture is not expect([postStatus, JSON.parse(postBody).code]).toEqual([404, 'FORM_NOT_FOUND']); }); - for (const posture of ['isolated', 'group'] as const) { - it(`walled ('${posture}'), walled object: both doors answer the withdrawn form's answer byte for byte, nothing is written`, async () => { - const s = build({ tenancy: posture }); - expect(answer(await s.get())).toEqual(await withdrawnAnswer('get')); - expect(answer(await s.post())).toEqual(await withdrawnAnswer('post')); - expect(s.createData).not.toHaveBeenCalled(); - }); - } - - it('ENUMERATION: every route under /forms/ is an anonymous form door, and each answers the withdrawn answer', async () => { - const s = build({ tenancy: 'isolated' }); - expect(s.formDoors.map((r) => `${r.method} ${r.path}`).sort()).toEqual([ - 'GET /api/v1/forms/:slug', - 'POST /api/v1/forms/:slug/submit', - ]); - for (const door of s.formDoors) { - const expected = await withdrawnAnswer(door.method === 'POST' ? 'post' : 'get'); - expect(answer(await s.drive(door, door.method)), `${door.method} ${door.path}`).toEqual(expected); - } - expect(s.createData).not.toHaveBeenCalled(); + // 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 });