diff --git a/.changeset/25574-public-form-organization.md b/.changeset/25574-public-form-organization.md new file mode 100644 index 00000000000..8d1f376c19d --- /dev/null +++ b/.changeset/25574-public-form-organization.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/metadata-core': minor +'@objectstack/metadata-protocol': minor +'@objectstack/rest': minor +--- + +feat(forms): a public form binds to the organizations it is published to (ADR-0138 D5) + +A form's `sharing` gains `organizations`: the organizations the public form is published to, by organization slug. On a deployment with several organizations (one per legal entity, say), each organization publishes the same package's form on its own link, and a submission on that link lands in that organization, under that organization's own number sequences: + +```ts +sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/supplier-registration', + organizations: ['acme', 'globex'], +} +``` + +- The link names the organization: `GET /api/v1/forms/supplier-registration?organization=acme` and `POST /api/v1/forms/supplier-registration/submit?organization=acme`. The server reads the organization carrying that slug, as the system, and the submission is written in it and only in it. +- A link that names no organization, names one the form does not list, names a listed slug no organization carries, or repeats the parameter is answered exactly like an unpublished form: `404 FORM_NOT_FOUND` on both doors, nothing written. A bound form is never sent to the default organization or to the first one it lists. +- A bound form is offered on a walled tenancy posture (`group`, `isolated`), where an unbound form over a walled object is withheld: its submission carries the organization its link names. The administrator's read of the view and the save/publish advisory no longer warn about such a form. +- A form without `organizations` behaves as before (ADR-0138 D4): its submission lands in the deployment's one organization, and a link that names an organization is refused rather than ignored. +- `organizations` must be a non-empty list of non-empty slugs; `organization`, `orgs` and `tenants` are refused and pointed at it. +- New exports: `anonymousFormIntakeOrganizations` (`@objectstack/spec/ui`, re-exported by `@objectstack/metadata-core`) reads a form's binding; `resolvePublicFormOrganization` (`@objectstack/core`) is the doors' decision. `anonymousFormIntakeUnavailability` (`@objectstack/metadata-core`) takes the form's binding as an optional fourth argument; omitted, the form is judged unbound. + +Clause-②: yes (widening) +Metadata-protocol: added diff --git a/content/docs/references/ui/sharing.mdx b/content/docs/references/ui/sharing.mdx index 912362d5c9e..3d771796e6b 100644 --- a/content/docs/references/ui/sharing.mdx +++ b/content/docs/references/ui/sharing.mdx @@ -60,6 +60,7 @@ const result = SharingConfigSchema.parse(data); | **allowedDomains** | `string[]` | optional | Restrict access to specific email domains (e.g. ["example.com"]) | | **expiresAt** | `string` | optional | Expiration date/time in ISO 8601 format | | **allowAnonymous** | `boolean` | optional (default: `false`) | Allow access without authentication | +| **organizations** | `string[]` | optional | The organizations this public form is published to, by organization slug (ADR-0138 D5). Absent: the form serves the organization the deployment resolves for an anonymous caller, which exists only on a deployment holding exactly one organization, and a link naming an organization is refused. Set: every public link names one of these organizations, as `/forms/:slug?organization=`, and a submission lands in that organization, under its own number sequences. A link naming no organization, or one not listed here, is answered as not found by both form doors. | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0f9e38ce50a..f0541ed5e72 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -491,6 +491,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **allowedDomains** | `string[]` | optional | Restrict access to specific email domains (e.g. ["example.com"]) | | **expiresAt** | `string` | optional | Expiration date/time in ISO 8601 format | | **allowAnonymous** | `boolean` | optional (default: `false`) | Allow access without authentication | +| **organizations** | `string[]` | optional | The organizations this public form is published to, by organization slug (ADR-0138 D5). Absent: the form serves the organization the deployment resolves for an anonymous caller, which exists only on a deployment holding exactly one organization, and a link naming an organization is refused. Set: every public link names one of these organizations, as `/forms/:slug?organization=`, and a submission lands in that organization, under its own number sequences. A link naming no organization, or one not listed here, is answered as not found by both form doors. | ### Nested Shape: `FormView.submitBehavior[kind='redirect']` diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 68cbc3ccd0d..6a4712d4cef 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -101,7 +101,25 @@ export default defineView({ > - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list. > - A form whose sections declare **no** fields collects nothing, so the submit is **refused** (`400 VALIDATION_ERROR`) rather than accepting whatever the caller sent (#6920). Its `GET /forms/:slug` publishes no schema either (#6601) — declare the fields and both planes come alive together. > - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed. -> - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again. +> - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again. A form that lists `sharing.organizations` is the exception: it is offered on any posture, at `/forms/:slug?organization=ORG_SLUG`, because its submission carries the organization its link names (below), and neither the view's read nor a save raises this warning for it. + +### Publishing one form to several organizations + +On a deployment with several organizations (one per legal entity, say), list the organizations the form is published to, by organization slug: + +```ts +sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/supplier-registration', + organizations: ['acme', 'globex'], +}, +``` + +- Each organization's link names it: `GET /api/v1/forms/supplier-registration?organization=acme` and `POST /api/v1/forms/supplier-registration/submit?organization=acme`. The server reads the organization carrying that slug, and the submission is written in that organization and only in it, so its number sequences count there. +- A link that names no organization, names one the form does not list, or names a listed slug no organization carries answers `404 FORM_NOT_FOUND` on both endpoints, exactly like an unpublished form, and nothing is written. A bound form is never sent to the default organization or to the first one it lists. +- `organizations` must be a non-empty list of non-empty slugs; `organization`, `orgs` and `tenants` are refused and pointed at it. +- A form without `organizations` serves the deployment's one organization, as above, and a link that names an organization is refused (`404 FORM_NOT_FOUND`) rather than ignored. ## 2. No permission set to create — what the `guest_portal` name does @@ -223,7 +241,7 @@ Errors: | `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) | | `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) | | `403 PERMISSION_DENIED` | never a permission set refusing the create (section 2). It answers when the security layer fails closed: the permission sets for the submission could not be resolved ("permission subsystem unavailable"), or the target object's security posture could not be resolved. It also answers when a write made under the submission's context leaves the grant, such as an `afterInsert` hook writing a **different** object through `ctx.api` (the grant covers only the form's object), or when a hook registered in code throws a `403` refusal of its own (`PermissionDeniedError`). A declarative hook's `throw` answers `400`, not `403`. | -| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` | +| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true`; or the `?organization=` the link names (or omits) is not one the form's `sharing.organizations` lists | | `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code | The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index c739997b31e..d12832a041f 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -282,6 +282,15 @@ export { type GuestOrganizationRefusalReason, } from './guest-organization.js'; +// [ADR-0138 D5] The organization a public form request acts in when the form +// is bound to organizations at publish time (`sharing.organizations`) — the +// form doors ask it before they read or write anything. +export { + resolvePublicFormOrganization, + type PublicFormOrganizationDecision, + type PublicFormOrganizationRefusalReason, +} from './public-form-organization.js'; + // [#18783] The EFFECTIVE object-permission map — ONE function behind both the // `/auth/me/permissions` `objects` slot and `ISecurityService.getEffectiveObjectPermissions` // (the map `current_user.can()` reads). The four folds moved here from diff --git a/packages/core/src/security/public-form-organization.test.ts b/packages/core/src/security/public-form-organization.test.ts new file mode 100644 index 00000000000..49669b671c1 --- /dev/null +++ b/packages/core/src/security/public-form-organization.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolvePublicFormOrganization } from './public-form-organization.js'; +import * as securityEntry from './index.js'; + +// ADR-0138 D5: the organization a public form request acts in. The doors answer +// every `refuse` as an unpublished form; what is pinned here is the decision. + +const ORGS: Record = { acme: ['org_acme'], globex: ['org_globex'], initech: ['org_initech'] }; + +function probeCounting() { + const asked: string[] = []; + return { + asked, + probe: async (slug: string) => { asked.push(slug); return ORGS[slug] ?? []; }, + }; +} + +describe('resolvePublicFormOrganization', () => { + it('is exported from the security entry as the same binding', () => { + expect(securityEntry.resolvePublicFormOrganization).toBe(resolvePublicFormOrganization); + }); + + it('unbound form, link naming none: unbound (ADR-0138 D4 decides), nothing read', async () => { + const { asked, probe } = probeCounting(); + expect(await resolvePublicFormOrganization({ published: [], requested: undefined, probeOrganizationsBySlug: probe })) + .toEqual({ kind: 'unbound' }); + expect(asked).toEqual([]); + }); + + it.each(['acme', 'globex'])('bound form, link naming listed `%s`: that organization and only it', async (slug) => { + const { asked, probe } = probeCounting(); + expect(await resolvePublicFormOrganization({ published: ['acme', 'globex'], requested: slug, probeOrganizationsBySlug: probe })) + .toEqual({ kind: 'bound', organization: slug, organizationId: ORGS[slug][0] }); + expect(asked).toEqual([slug]); + }); + + it.each<[string, readonly string[], unknown, string]>([ + // ⛔ never the default organization, never the first listed one. + ['bound form, link naming none', ['acme', 'globex'], undefined, 'organization-required'], + ['bound form of one organization, link naming none', ['acme'], undefined, 'organization-required'], + ['bound form, link naming an existing organization it does not list', ['acme', 'globex'], 'initech', 'not-published'], + ['unbound form, link naming an organization', [], 'acme', 'not-published'], + ['a repeated parameter', ['acme'], ['acme', 'acme'], 'malformed-organization'], + ['an empty parameter', ['acme'], '', 'malformed-organization'], + ['a non-string parameter', ['acme'], { slug: 'acme' }, 'malformed-organization'], + ])('%s: refused (%s), and the database is never asked', async (_label, published, requested, reason) => { + const { asked, probe } = probeCounting(); + expect(await resolvePublicFormOrganization({ published, requested, probeOrganizationsBySlug: probe })) + .toEqual({ kind: 'refuse', reason }); + expect(asked).toEqual([]); + }); + + it('a listed slug that no organization carries: refused (unknown-organization)', async () => { + const { asked, probe } = probeCounting(); + expect(await resolvePublicFormOrganization({ published: ['acme', 'ghost'], requested: 'ghost', probeOrganizationsBySlug: probe })) + .toEqual({ kind: 'refuse', reason: 'unknown-organization' }); + expect(asked).toEqual(['ghost']); + }); + + it('a listed slug two organizations carry (a broken uniqueness invariant): refused, never a pick', async () => { + expect(await resolvePublicFormOrganization({ + published: ['acme'], + requested: 'acme', + probeOrganizationsBySlug: async () => ['org_a', 'org_b'], + })).toEqual({ kind: 'refuse', reason: 'unknown-organization' }); + }); + + it('the slug match is exact: a case variant of a listed slug is not listed', async () => { + const { asked, probe } = probeCounting(); + expect(await resolvePublicFormOrganization({ published: ['acme'], requested: 'ACME', probeOrganizationsBySlug: probe })) + .toEqual({ kind: 'refuse', reason: 'not-published' }); + expect(asked).toEqual([]); + }); + + it('a failed organization read propagates (fail closed), never read as "no organization"', async () => { + const outage = new Error('pool exhausted'); + await expect(resolvePublicFormOrganization({ + published: ['acme'], + requested: 'acme', + probeOrganizationsBySlug: async () => { throw outage; }, + })).rejects.toBe(outage); + }); +}); diff --git a/packages/core/src/security/public-form-organization.ts b/packages/core/src/security/public-form-organization.ts new file mode 100644 index 00000000000..b495e9203b9 --- /dev/null +++ b/packages/core/src/security/public-form-organization.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 D5 — the organization a public form request acts in when the form + * is bound to organizations at publish time. + * + * ## The rule + * + * A form's `sharing.organizations` (`@objectstack/spec/ui`, `SharingConfigSchema`) + * lists the organizations the form is published to, by organization slug, and + * the link names one of them (`?organization=SLUG`). The decision has three + * outcomes: + * + * - **unbound** — the form lists no organization and the link names none. + * ADR-0138 D4 is unchanged for it: the submission carries no organization + * and the engine derives the deployment's one organization, or refuses + * (`resolveSystemWriteOrganization`, ADR-0131 D9). Nothing here is asked. + * - **bound** — the link names an organization the form lists, and exactly one + * organization carries that slug. The request acts in that organization, and + * only in it: the door threads its id as the write's `tenantId`, so the row + * lands there and its number sequences count there. + * - **refuse** — every other combination. A bound form whose link names no + * organization is refused, ⛔ never answered with the default organization + * or the form's first listed one: on a multi-organization deployment that + * pick is exactly the silent guess D4 item 1 forbids. A link naming an + * organization the form does not list is refused, unbound forms included, + * because ignoring the name would land the row in an organization the link + * did not ask for. + * + * The organization comes from the server's own read of the slug the form's + * declaration lists, never from an id the client sends (D4 item 1): the client + * names a slug, and the slug only selects among the organizations the author + * published to. + * + * ## What the caller is told + * + * The doors answer every refusal exactly as they answer a form that is not + * published, so an anonymous caller learns nothing about which organizations + * exist or which a form lists. The reason is for the server's own log. + */ + +/** Why a public form request resolves no organization. Server-side only. */ +export type PublicFormOrganizationRefusalReason = + /** The form lists organizations and the link names none. */ + | 'organization-required' + /** The link names an organization the form does not list (or the form lists none). */ + | 'not-published' + /** The link names the organization in a shape that is not one slug (repeated, empty, not a string). */ + | 'malformed-organization' + /** The form lists the slug, and no organization carries it. */ + | 'unknown-organization'; + +/** What a public form request's organization resolves to (ADR-0138 D5). */ +export type PublicFormOrganizationDecision = + /** No binding asked for and none declared: ADR-0138 D4 decides, unchanged. */ + | { kind: 'unbound' } + /** The organization the link names, published to by the form. */ + | { kind: 'bound'; organization: string; organizationId: string } + /** No organization: the doors answer the form as not published. */ + | { kind: 'refuse'; reason: PublicFormOrganizationRefusalReason }; + +/** + * Resolve the organization a public form request acts in (ADR-0138 D5). + * + * `published` is the form's declared binding (`anonymousFormIntakeOrganizations` + * over its `sharing`); `requested` is the link's `organization` query value as + * the transport parsed it, unread until here. `probeOrganizationsBySlug` reads + * the ids of the organizations carrying a slug, as the system, and is called + * only once the slug is one the form lists — an unlisted slug is refused + * without asking the database anything. + */ +export async function resolvePublicFormOrganization(args: { + published: readonly string[]; + requested: unknown; + /** Organization ids carrying `slug`, capped at 2 by the caller — only "0 / 1 / several" matters. */ + probeOrganizationsBySlug: (slug: string) => Promise; +}): Promise { + const { published, requested } = args; + if (requested === undefined) { + return published.length === 0 + ? { kind: 'unbound' } + : { kind: 'refuse', reason: 'organization-required' }; + } + if (typeof requested !== 'string' || requested === '') { + return { kind: 'refuse', reason: 'malformed-organization' }; + } + if (!published.includes(requested)) return { kind: 'refuse', reason: 'not-published' }; + const ids = await args.probeOrganizationsBySlug(requested); + // Slugs are unique across the deployment (`sys_organization`'s global unique + // index), so several rows is a broken invariant, refused like none. + if (ids.length !== 1) return { kind: 'refuse', reason: 'unknown-organization' }; + return { kind: 'bound', organization: requested, organizationId: ids[0] }; +} diff --git a/packages/metadata-core/src/anonymous-form-intake.test.ts b/packages/metadata-core/src/anonymous-form-intake.test.ts index fb5c77f3230..90f3ba4d7d3 100644 --- a/packages/metadata-core/src/anonymous-form-intake.test.ts +++ b/packages/metadata-core/src/anonymous-form-intake.test.ts @@ -104,18 +104,18 @@ describe('anonymousFormIntakeUnavailability — the intake-availability predicat const raw = (extra: Record = {}) => ({ name: 'inquiry', fields: { email: { type: 'text' } }, ...extra }); it.each(['isolated', 'group'] as const)("'%s': a walled object is unavailable, naming the object, the posture and the column", (posture) => { - expect(anonymousFormIntakeUnavailability('inquiry', posture, () => served())) + expect(anonymousFormIntakeUnavailability('inquiry', posture, () => served(), [])) .toEqual({ object: 'inquiry', posture, tenantField: 'organization_id' }); }); it('judges the EFFECTIVE schema: a stored body with no declared organization_id is walled all the same', () => { - expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => raw())) + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => raw(), [])) .toEqual({ object: 'inquiry', posture: 'isolated', tenantField: 'organization_id' }); }); it('a declared tenancy.tenantField the object really has is the column named', () => { const schema = served({ tenancy: { tenantField: 'company_id' }, fields: { company_id: { type: 'text' } } }); - expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema)?.tenantField).toBe('company_id'); + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema, [])?.tenantField).toBe('company_id'); }); it.each<[string, unknown]>([ @@ -123,7 +123,7 @@ describe('anonymousFormIntakeUnavailability — the intake-availability predicat ['an object the universe does not hold', undefined], ['an object with no fields record and no wall', { name: 'inquiry', systemFields: false }], ])('CONTROL, walled posture — %s: available', (_label, schema) => { - expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema)).toBeNull(); + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => schema, [])).toBeNull(); }); it.each<[string, 'single' | undefined]>([ @@ -131,15 +131,28 @@ describe('anonymousFormIntakeUnavailability — the intake-availability predicat ['no tenancy service (no posture)', undefined], ])('CONTROL — %s: available, and the object is never read', (_label, posture) => { let reads = 0; - expect(anonymousFormIntakeUnavailability('inquiry', posture, () => { reads += 1; return served(); })).toBeNull(); + expect(anonymousFormIntakeUnavailability('inquiry', posture, () => { reads += 1; return served(); }, [])).toBeNull(); expect(reads).toBe(0); }); it('an asynchronous reader gets a promise when a wall is in force, and null without reading otherwise', async () => { - const walled = anonymousFormIntakeUnavailability('inquiry', 'group', async () => served()); + const walled = anonymousFormIntakeUnavailability('inquiry', 'group', async () => served(), []); expect(walled).toBeInstanceOf(Promise); expect(await walled).toEqual({ object: 'inquiry', posture: 'group', tenantField: 'organization_id' }); - expect(anonymousFormIntakeUnavailability('inquiry', 'single', async () => served())).toBeNull(); + expect(anonymousFormIntakeUnavailability('inquiry', 'single', async () => served(), [])).toBeNull(); + }); + + // [ADR-0138 D5] A form bound to organizations at publish time carries the + // organization its link names, so the posture reason never applies to it. + it.each(['isolated', 'group'] as const)("'%s': a form bound to organizations is available, and the object is never read", (posture) => { + let reads = 0; + expect(anonymousFormIntakeUnavailability('inquiry', posture, () => { reads += 1; return served(); }, ['acme'])).toBeNull(); + expect(reads).toBe(0); + }); + + it('the binding omitted is judged unbound: it fails closed, never open', () => { + expect(anonymousFormIntakeUnavailability('inquiry', 'isolated', () => served())) + .toEqual({ object: 'inquiry', posture: 'isolated', tenantField: 'organization_id' }); }); }); @@ -325,6 +338,7 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same describe('the candidates half is the spec binding itself, re-exported (one copy, not a copy)', () => { const NAMES = [ 'publicFormSlug', + 'anonymousFormIntakeOrganizations', 'anonymousFormIntakeSlug', 'anonymousFormIntakeCandidates', 'anonymousFormIntakeSlugs', diff --git a/packages/metadata-core/src/anonymous-form-intake.ts b/packages/metadata-core/src/anonymous-form-intake.ts index c086718754d..f9f68570411 100644 --- a/packages/metadata-core/src/anonymous-form-intake.ts +++ b/packages/metadata-core/src/anonymous-form-intake.ts @@ -51,6 +51,7 @@ import { resolveRecordWallOrganizationField } from './record-organization.js'; // two entries cannot disagree; the identity is pinned in this module's test. export { anonymousFormIntakeCandidates, + anonymousFormIntakeOrganizations, anonymousFormIntakeSlug, anonymousFormIntakeSlugs, publicFormSlug, @@ -142,6 +143,16 @@ function judgeObjectSchema( * EFFECTIVE schema (its declared fields plus the injected columns, as a * served object document carries them). * + * [ADR-0138 D5] A form bound to organizations at publish time + * (`organizations`, the form's `sharing.organizations` as + * `anonymousFormIntakeOrganizations` reads it) is never unavailable here: its + * submission carries the organization its link names, so the reason this + * predicate states — an anonymous submission carries no organization — does + * not hold for it. Whether the link names a listed organization is the door's + * per-request question (`resolvePublicFormOrganization`, `@objectstack/core`). + * Omitting the binding judges the form as unbound, which fails closed: a bound + * form is withheld on a walled posture, never opened by mistake. + * * `readObjectSchema` runs only once a wall is in force. A synchronous reader * gets a synchronous answer (the authoring gate); a reader that returns a * promise gets a promise, or `null` when nothing had to be read — `await` @@ -156,17 +167,21 @@ export function anonymousFormIntakeUnavailability( object: string, posture: TenancyPosture | undefined, readObjectSchema: () => PromiseLike, + organizations?: readonly string[], ): Promise | null; export function anonymousFormIntakeUnavailability( object: string, posture: TenancyPosture | undefined, readObjectSchema: () => unknown, + organizations?: readonly string[], ): AnonymousFormIntakeUnavailable | null; export function anonymousFormIntakeUnavailability( object: string, posture: TenancyPosture | undefined, readObjectSchema: () => unknown, + organizations: readonly string[] = [], ): Promise | AnonymousFormIntakeUnavailable | null { + if (organizations.length > 0) return null; if (posture === undefined || !postureEnforcesWall(posture)) return null; const objectSchema = readObjectSchema(); if (isPromiseLike(objectSchema)) { diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 27fd3dbac7b..6998869a1f5 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -84,6 +84,7 @@ import type { TenancyPosture } from '@objectstack/spec/security'; // location and in the words the admin read uses. import { anonymousFormIntakeCandidates, + anonymousFormIntakeOrganizations, anonymousFormIntakeUnavailability, anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, @@ -439,6 +440,7 @@ export function findPublicFormIntakeGaps(args: { object, args.tenancyPostureInForce, (): unknown => args.objects.find((o) => isRec(o) && o.name === object), + anonymousFormIntakeOrganizations(candidate.form.sharing), ); if (!unavailable) continue; issues.push({ diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 9d6ebc8a81e..35609706582 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -67,15 +67,16 @@ import { anonymousFormIntakeCandidates, + anonymousFormIntakeOrganizations, anonymousFormIntakePosture, anonymousFormIntakeUnavailability, anonymousFormIntakeWithdrawnIn, anonymousFormObjectName, } from '@objectstack/metadata-core'; -import { isEmptyForRequired, isMissing } from '@objectstack/core'; -import type { CreateDataRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; +import { isEmptyForRequired, isMissing, resolvePublicFormOrganization } from '@objectstack/core'; +import type { CreateDataRequest, FindDataRequest, GetMetaItemsRequest } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; -import { renderValidationMessage } from '@objectstack/spec/system'; +import { renderValidationMessage, SystemObjectName } from '@objectstack/spec/system'; import { validationFailure } from '@objectstack/types'; import type { ServerScopedDataRequest } from './data-action-routes.js'; import { isExpectedRouteError, mapDataError } from './error-response.js'; @@ -171,11 +172,24 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // supported no-tenancy composition. The wiring mirrors // `resolveProtocol`, so the tenancy service and the protocol always // come from the same kernel. + // + // [ADR-0138 D5] …and WHICH organization the request acts in. A form whose + // `sharing.organizations` lists organizations is published to those, and + // its link names one (`?organization=SLUG`); the request acts in that + // organization and only in it (`organizationId`, threaded by the submit + // door as the write's `tenantId`). A form that lists none is ADR-0138 + // D4's, unchanged: no organization here, the engine derives the + // deployment's one or refuses. Every other combination — a bound form's + // link naming no organization or an unlisted one, an unbound form's link + // naming any, a listed slug no organization carries — is answered + // exactly as an unpublished form (`null`), so an anonymous caller learns + // nothing about which organizations exist or which a form lists. + // `resolvePublicFormOrganization` (`@objectstack/core`) is the decision. const resolveFormBySlug = async ( environmentId: string | undefined, req: any, slug: string, - ): Promise<{ view: any; form: any; object: string } | null> => { + ): Promise<{ view: any; form: any; object: string; organizationId?: string } | null> => { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); @@ -191,16 +205,42 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo const items = listOf(await p.getMetaItems(viewsRequest)); const match = findPublicFormView(items, slug); if (!match) return null; + const organizations = anonymousFormIntakeOrganizations(match.form?.sharing); + const organization = await resolvePublicFormOrganization({ + published: organizations, + requested: req.query?.organization, + // The server's own read, as the system: the slug only selects + // among the organizations the form's declaration lists. + probeOrganizationsBySlug: async (organizationSlug) => { + const probe: ServerScopedDataRequest = { + object: SystemObjectName.ORGANIZATION, + query: { object: SystemObjectName.ORGANIZATION, where: { slug: organizationSlug }, fields: ['id'], limit: 2 }, + ...(environmentId ? { environmentId } : {}), + context: { isSystem: true }, + }; + const found: any = await p.findData(probe); + const rows: any[] = Array.isArray(found?.records) ? found.records : []; + return rows + .map((row) => row?.id) + .filter((id: unknown) => id !== undefined && id !== null && String(id) !== '') + .map((id: unknown) => String(id)); + }, + }); + if (organization.kind === 'refuse') 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. + // A bound form is never unavailable on posture: its submission + // carries the organization its link names. const unavailable = await anonymousFormIntakeUnavailability( match.object, anonymousFormIntakePosture(tenancy), async () => (await this.readFormObjectDefinitions(p, environmentId)) .find((o: any) => o?.name === match.object), + organizations, ); - return unavailable ? null : match; + if (unavailable) return null; + return organization.kind === 'bound' ? { ...match, organizationId: organization.organizationId } : match; }; // GET /forms/:slug — resolve and return the public form spec @@ -559,10 +599,16 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // the engine builds `ctx.session` and `ctx.user` without either // key. With no `userId` here, `ctx.user` is unset, so a hook sees // this submission as a write with no user, like any other. + // + // [ADR-0138 D5] A bound form's submission acts in the + // organization its link names: `tenantId` is the write + // target the engine stamps and the number sequences count + // under. An unbound form's carries none (ADR-0138 D4). const context: any = { publicFormGrant: { object: match.object }, permissions: ['guest_portal'], anonymous: true, + ...(match.organizationId !== undefined ? { tenantId: match.organizationId } : {}), }; const p = await this.resolveProtocol(environmentId, req); diff --git a/packages/rest/src/public-form-organization.test.ts b/packages/rest/src/public-form-organization.test.ts new file mode 100644 index 00000000000..5a18370cf93 --- /dev/null +++ b/packages/rest/src/public-form-organization.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0138 D5] A public form bound to organizations at publish time +// (`sharing.organizations`, by slug) is served on links that name one of them +// (`?organization=SLUG`), and a submission on such a link lands in that +// organization: the door threads its id as the write's `tenantId`. Pinned on +// both doors: each listed organization's link acts in that organization and +// only in it; every other link — naming none, naming an unlisted organization, +// naming a listed slug no organization carries, a repeated parameter — answers +// the unpublished form's answer byte for byte, writes nothing, and (but for the +// listed-slug probe) reads no organization; a bound form is offered on a walled +// posture, and the administrator's read raises no intake warning for it. The +// unbound form keeps ADR-0138 D4 exactly: no organization on the write, and a +// link naming one is refused. + +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 SLUG = 'supplier-registration'; +/** The deployment's organizations, by slug, as `sys_organization` holds them. */ +const ORGANIZATIONS: Record = { acme: 'org_acme', globex: 'org_globex', initech: 'org_initech' }; + +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; `organizations` absent is the unbound form. */ +const formView = (sharing: Record) => ({ + name: 'supplier_registration', object: 'supplier', viewKind: 'form', _diagnostics: { valid: true }, + config: { + data: { object: 'supplier' }, + sections: [{ fields: ['name', 'email'] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${SLUG}`, ...sharing }, + }, +}); + +/** The object as the doors read it: walled by the injected `organization_id`. */ +const supplierObject = { + name: 'supplier', label: 'Supplier', + fields: { + organization_id: { type: 'lookup', reference: 'sys_organization' }, + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + }, +}; + +interface Setup { + posture: 'isolated' | 'single'; + sharing: Record; +} + +function build(setup: Setup) { + const createData = vi.fn().mockResolvedValue({ object: 'supplier', id: 'rec_1', record: {} }); + const findData = vi.fn(async (req: any) => { + const slug = req?.query?.where?.slug; + const id = req?.object === 'sys_organization' && typeof slug === 'string' ? ORGANIZATIONS[slug] : undefined; + return { object: req?.object, records: id ? [{ id }] : [], hasMore: false }; + }); + const getMetaItems = vi.fn(async (req: { type: string }) => { + if (req.type === 'view') return [formView(setup.sharing)]; + if (req.type === 'object') return [supplierObject]; + return []; + }); + 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.sharing) })), + createData, + findData, + }; + const tenancyServiceProvider = async () => ({ + posture: setup.posture, requestedPosture: setup.posture, defaultOrgId: async () => null, + }); + 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 drive = async (method: 'GET' | 'POST', query: Record) => { + const route = method === 'GET' ? find('GET', '/api/v1/forms/:slug') : find('POST', '/api/v1/forms/:slug/submit'); + const res = mockRes(); + const body = method === 'POST' ? { body: { name: 'Acme Supplies', email: 'sales@example.com' } } : {}; + await route.handler({ params: { slug: SLUG }, query, headers: {}, ...body } as any, res); + return res; + }; + return { + createData, findData, drive, + async adminRead() { + const res = mockRes(); + await find('GET', '/api/v1/meta/:type/:name').handler({ params: { type: 'view', name: 'supplier_registration' }, query: {}, headers: {} } as any, res); + return res; + }, + }; +} + +const answer = (res: any): [number, string] => [res.statusCode, JSON.stringify(res.body)]; + +/** The unpublished form's answer on a door — what every refused link must match byte for byte. */ +async function unpublishedAnswer(method: 'GET' | 'POST'): Promise<[number, string]> { + const s = build({ posture: 'isolated', sharing: { allowAnonymous: false, organizations: ['acme'] } }); + return answer(await s.drive(method, { organization: 'acme' })); +} + +const BOUND = { organizations: ['acme', 'globex'] }; + +describe('[ADR-0138 D5] a bound form acts in the organization its link names', () => { + it('REFERENCE: the unpublished form answers 404 FORM_NOT_FOUND on both doors', async () => { + for (const method of ['GET', 'POST'] as const) { + const [status, body] = await unpublishedAnswer(method); + expect([status, JSON.parse(body).code]).toEqual([404, 'FORM_NOT_FOUND']); + } + }); + + for (const posture of ['isolated', 'single'] as const) { + it.each(['acme', 'globex'])(`'${posture}': the link naming listed '%s' resolves, and the submission lands in that organization only`, async (slug) => { + const s = build({ posture, sharing: BOUND }); + const get = await s.drive('GET', { organization: slug }); + expect(get.statusCode).toBe(200); + expect(get.body.object).toBe('supplier'); + + const post = await s.drive('POST', { organization: slug }); + expect([post.statusCode, post.body]).toEqual([201, { id: 'rec_1' }]); + expect(s.createData).toHaveBeenCalledTimes(1); + const request = s.createData.mock.calls[0][0]; + expect(request.object).toBe('supplier'); + expect(request.context).toEqual({ + publicFormGrant: { object: 'supplier' }, + permissions: ['guest_portal'], + anonymous: true, + tenantId: ORGANIZATIONS[slug], + }); + // The organization is the server's read of the listed slug, as the system. + expect(s.findData.mock.calls.map(([r]) => [r.object, r.query.where, r.context])).toEqual([ + ['sys_organization', { slug }, { isSystem: true }], + ['sys_organization', { slug }, { isSystem: true }], + ]); + }); + } + + it.each<[string, Record, boolean]>([ + // ⛔ never the default organization, never the first listed one. + ['naming no organization', {}, false], + ['naming an existing organization the form does not list', { organization: 'initech' }, false], + ['naming a case variant of a listed slug', { organization: 'ACME' }, false], + ['repeating the parameter', { organization: ['acme', 'globex'] }, false], + ['naming an empty organization', { organization: '' }, false], + ])('a bound form\'s link %s: the unpublished answer on both doors, nothing written, no organization read', async (_label, query) => { + for (const method of ['GET', 'POST'] as const) { + const s = build({ posture: 'isolated', sharing: BOUND }); + expect(answer(await s.drive(method, query))).toEqual(await unpublishedAnswer(method)); + expect(s.createData).not.toHaveBeenCalled(); + expect(s.findData).not.toHaveBeenCalled(); + } + }); + + it('a listed slug no organization carries: the unpublished answer on both doors, nothing written', async () => { + for (const method of ['GET', 'POST'] as const) { + const s = build({ posture: 'isolated', sharing: { organizations: ['acme', 'ghost'] } }); + expect(answer(await s.drive(method, { organization: 'ghost' }))).toEqual(await unpublishedAnswer(method)); + expect(s.createData).not.toHaveBeenCalled(); + expect(s.findData).toHaveBeenCalledTimes(1); + } + }); + + it('the administrator\'s read raises no intake warning for a bound form on a walled posture', async () => { + const res = await build({ posture: 'isolated', sharing: BOUND }).adminRead(); + expect(res.statusCode).toBe(200); + expect(res.body.item._diagnostics).toEqual({ valid: true }); + }); + + it('CONTROL: the same form unbound on a walled posture is still not offered, and the admin read says why', async () => { + const s = build({ posture: 'isolated', sharing: {} }); + for (const method of ['GET', 'POST'] as const) { + expect(answer(await s.drive(method, {}))).toEqual(await unpublishedAnswer(method)); + } + expect((await s.adminRead()).body.item._diagnostics.warnings).toHaveLength(1); + }); +}); + +describe('[ADR-0138 D4, unchanged] the unbound form', () => { + it('a link naming no organization: served, and the write carries no organization (the engine derives it)', async () => { + const s = build({ posture: 'single', sharing: {} }); + expect((await s.drive('GET', {})).statusCode).toBe(200); + expect((await s.drive('POST', {})).statusCode).toBe(201); + expect(s.createData.mock.calls[0][0].context).toEqual({ + publicFormGrant: { object: 'supplier' }, + permissions: ['guest_portal'], + anonymous: true, + }); + expect(s.findData).not.toHaveBeenCalled(); + }); + + it('a link naming an organization: the unpublished answer on both doors — never ignored into the default organization', async () => { + for (const method of ['GET', 'POST'] as const) { + const s = build({ posture: 'single', sharing: {} }); + expect(answer(await s.drive(method, { organization: 'acme' }))).toEqual(await unpublishedAnswer(method)); + expect(s.createData).not.toHaveBeenCalled(); + expect(s.findData).not.toHaveBeenCalled(); + } + }); +}); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index d6cf5fa69bc..03ecb01c447 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -161,9 +161,10 @@ interface CensusEntry { * `query:` slot. * * [#23050 slice 11] 4 = 1 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0: the - * public form doors (`form-routes.ts`) build no `query:` literal either — they - * list the environment's views and objects by `type` and insert through - * `createData`, never through a `query:` slot. + * public form doors (`form-routes.ts`) built no `query:` literal when they + * moved — they listed the environment's views and objects by `type` and + * inserted through `createData`, never through a `query:` slot. (Superseded + * for this file by the [#25574] entry below.) * * [#23050 slice 12] 4 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1, a * MOVE: `rest-server.ts`' one slot, the list door's caller-supplied @@ -198,6 +199,14 @@ interface CensusEntry { * unchanged: neither the object state-machine read (`meta-object-state-routes.ts`) * nor the published read (now in `meta-item-history-routes.ts`) builds a `query:` * slot, so the new module's floor is 0 and the history module's stays 0. + * + * [#25574] 5 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 0: + * the public form doors (`form-routes.ts`) now build one `query:` slot, the + * server's own read of a bound form's organization (ADR-0138 D5, + * `probeOrganizationsBySlug` in `resolveFormBySlug`): + * `query: { object, where: { slug }, fields: ['id'], limit: 2 }`, canonical + * QueryAST slots with no wire alias and no cast. Its floor goes 0 → 1, its + * measured count, and the sum 4 → 5. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -211,7 +220,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly { // carries no slot). // [#23050 slice 13e] Length 17 → 18, the sum unchanged (neither the object // state-machine read nor the published read carries a slot). + // [#25574] Length unchanged, the sum 4 → 5: `form-routes.ts` builds one + // slot, the bound form's organization read (ADR-0138 D5). expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(18); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); - expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(4); + expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(5); }); it('dropping comment lines leaves the code that is being censused', () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 83f58a2ac0b..43ad38e1f8f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -79,6 +79,7 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, + anonymousFormIntakeOrganizations, // `anonymousFormIntakeWithdrawnIn` (whether another body of the same view name withdraws a // candidate), read only by the anonymous form doors, moved with `registerFormEndpoints`' // body to `form-routes.ts` (#23050 slice 11). @@ -5875,6 +5876,7 @@ export class RestServer { object, posture, async () => (await readObjects()).find((o: any) => o?.name === object), + anonymousFormIntakeOrganizations(candidate.form.sharing), ); if (!unavailable) continue; warnings.push({ diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c302c6d1d30..eecd7f58f1f 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -492,6 +492,7 @@ "WidgetColorVariantSchema (const)", "actionForm (const)", "anonymousFormIntakeCandidates (function)", + "anonymousFormIntakeOrganizations (function)", "anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs (function)", "appForm (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 985a29bc6b4..00ae5f50b62 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1321,6 +1321,7 @@ "ui/SharingConfig:allowedDomains", "ui/SharingConfig:enabled", "ui/SharingConfig:expiresAt", + "ui/SharingConfig:organizations", "ui/SharingConfig:password", "ui/SharingConfig:publicLink", "ui/TimelineConfig:colorField", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index ad631f70cf8..a6b0bafa8a5 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -477,6 +477,7 @@ "WidgetColorVariantSchema": "src/ui/dashboard.zod.ts#WidgetColorVariantSchema (const)", "actionForm": "src/ui/action.form.ts#actionForm (const)", "anonymousFormIntakeCandidates": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (function)", + "anonymousFormIntakeOrganizations": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeOrganizations (function)", "anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)", "anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)", "appForm": "src/ui/app.form.ts#appForm (const)", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index f2190cd3e76..2b2056a843c 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -355,7 +355,7 @@ "sharing": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/form-routes.ts#findPublicFormView (`for (const c of anonymousFormIntakeCandidates(view))` then `if (c.slug !== slug) continue;` — the public form doors serve only a candidate whose slug matches); packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (hands each form's `sharing` to `anonymousFormIntakeSlug`, across the nested `form`, every `formViews` entry and the flattened `config` of a `viewKind: 'form'` item); packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (the slug rule reads all three keys: `if (s.enabled !== true) return null;`, `if (s.allowAnonymous !== true) return null;`, `if (typeof s.publicLink !== 'string' || !s.publicLink) return null;`); packages/rest/src/form-routes.ts#resolveFormBySlug (both doors resolve the match through it, and the submit door grants `publicFormGrant: { object: match.object }` for the matched form's object)", + "evidence": "packages/rest/src/form-routes.ts#findPublicFormView (`for (const c of anonymousFormIntakeCandidates(view))` then `if (c.slug !== slug) continue;` — the public form doors serve only a candidate whose slug matches); packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (hands each form's `sharing` to `anonymousFormIntakeSlug`, across the nested `form`, every `formViews` entry and the flattened `config` of a `viewKind: 'form'` item); packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (the slug rule reads all three keys: `if (s.enabled !== true) return null;`, `if (s.allowAnonymous !== true) return null;`, `if (typeof s.publicLink !== 'string' || !s.publicLink) return null;`); packages/rest/src/form-routes.ts#resolveFormBySlug (both doors resolve the match through it, and the submit door grants `publicFormGrant: { object: match.object }` for the matched form's object); packages/core/src/security/public-form-organization.ts#resolvePublicFormOrganization (ADR-0138 D5: `sharing.organizations` — both doors resolve the link's `?organization=` against the listed slugs through it, answer an unlisted or missing one as not found, and the submit door threads the bound organization as the write's `tenantId`)", "note": "Framework-side consumer: public (anonymous) form endpoints opt in via FormView.sharing — /forms/:slug resolves sharing.publicLink across form + formViews (rest-server.ts:1716, :4366-4405) and grants publicFormGrant. Renderer-side the config is not read (audit L20) — the public form is served, not re-parsed client-side. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:4384` had rotted onto a comment about `api`-only route enumeration, ~4,020 lines above the form endpoints; the note's own inline pointers (`:1716`, `:4366-4405`) had gone with it and are superseded by the anchors. Re-closed by hand against 8cb96ec41." }, "submitBehavior": { diff --git a/packages/spec/src/ui/anonymous-form-intake.test.ts b/packages/spec/src/ui/anonymous-form-intake.test.ts index 9f6c774bc00..aaf7354074f 100644 --- a/packages/spec/src/ui/anonymous-form-intake.test.ts +++ b/packages/spec/src/ui/anonymous-form-intake.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { SharingConfigSchema } from './sharing.zod'; import { anonymousFormIntakeCandidates, + anonymousFormIntakeOrganizations, anonymousFormIntakeSlug, anonymousFormIntakeSlugs, publicFormSlug, @@ -23,6 +24,7 @@ describe('the /ui entry exports the candidates half', () => { 'anonymousFormIntakeSlug', 'anonymousFormIntakeCandidates', 'anonymousFormIntakeSlugs', + 'anonymousFormIntakeOrganizations', ] as const)('%s is the module function', (name) => { expect(typeof uiEntry[name]).toBe('function'); }); @@ -32,6 +34,26 @@ describe('the /ui entry exports the candidates half', () => { expect(uiEntry.anonymousFormIntakeSlug).toBe(anonymousFormIntakeSlug); expect(uiEntry.anonymousFormIntakeCandidates).toBe(anonymousFormIntakeCandidates); expect(uiEntry.anonymousFormIntakeSlugs).toBe(anonymousFormIntakeSlugs); + expect(uiEntry.anonymousFormIntakeOrganizations).toBe(anonymousFormIntakeOrganizations); + }); +}); + +describe('anonymousFormIntakeOrganizations: the organizations a form is published to (ADR-0138 D5)', () => { + it('the declared list, as written', () => { + expect(anonymousFormIntakeOrganizations({ ...OPEN, organizations: ['acme', 'globex'] })).toEqual(['acme', 'globex']); + }); + + it.each<[string, unknown]>([ + ['no organizations key', OPEN], + ['no sharing', undefined], + ['not a list', { ...OPEN, organizations: 'acme' }], + ])('%s: unbound, []', (_label, sharing) => { + expect(anonymousFormIntakeOrganizations(sharing)).toEqual([]); + }); + + it('a parsed body reads the same as the raw one', () => { + const raw = { ...OPEN, organizations: ['acme'] }; + expect(anonymousFormIntakeOrganizations(SharingConfigSchema.parse(raw))).toEqual(anonymousFormIntakeOrganizations(raw)); }); }); diff --git a/packages/spec/src/ui/anonymous-form-intake.ts b/packages/spec/src/ui/anonymous-form-intake.ts index 2056c8f654e..38a1ba23f63 100644 --- a/packages/spec/src/ui/anonymous-form-intake.ts +++ b/packages/spec/src/ui/anonymous-form-intake.ts @@ -80,6 +80,20 @@ export function anonymousFormIntakeSlug(sharing: unknown): string | null { return publicFormSlug(s.publicLink); } +/** + * The organizations a form's `sharing` publishes it to (ADR-0138 D5), by + * organization slug, as declared — or `[]` when the form names none, which is + * the unbound form ADR-0138 D4 serves in the organization the deployment + * resolves. Reads the declaration only: whether a listed slug names an + * organization that exists is the server's question, asked per request. + */ +export function anonymousFormIntakeOrganizations(sharing: unknown): string[] { + if (!sharing || typeof sharing !== 'object') return []; + const organizations = (sharing as Record).organizations; + if (!Array.isArray(organizations)) return []; + return organizations.filter((o): o is string => typeof o === 'string' && o !== ''); +} + /** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */ export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] { if (!view || typeof view !== 'object') return []; diff --git a/packages/spec/src/ui/sharing.test.ts b/packages/spec/src/ui/sharing.test.ts index 24c74b7769c..dab2ac11d79 100644 --- a/packages/spec/src/ui/sharing.test.ts +++ b/packages/spec/src/ui/sharing.test.ts @@ -59,6 +59,39 @@ describe('SharingConfigSchema', () => { }); }); +// [ADR-0138 D5] The organization binding: the organizations a public form is +// published to, by slug. Absent is the unbound form ADR-0138 D4 serves. +describe('SharingConfigSchema.organizations', () => { + it('absent: parses with no binding (no default is invented)', () => { + expect(SharingConfigSchema.parse({ enabled: true }).organizations).toBeUndefined(); + }); + + it('a list of slugs parses as written', () => { + const r = SharingConfigSchema.safeParse({ + enabled: true, allowAnonymous: true, publicLink: 'supplier-registration', organizations: ['acme', 'globex'], + }); + expect(r.success).toBe(true); + expect(r.success && r.data.organizations).toEqual(['acme', 'globex']); + }); + + it.each<[string, unknown]>([ + ['an empty list (publishes to nobody: say nothing, or name one)', []], + ['an empty slug', ['acme', '']], + ['a bare string instead of a list', 'acme'], + ['a non-string member', [42]], + ])('refused at authoring — %s', (_label, organizations) => { + const r = SharingConfigSchema.safeParse({ enabled: true, organizations }); + expect(r.success).toBe(false); + expect((r.success ? [] : r.error.issues).some((i) => i.path[0] === 'organizations')).toBe(true); + }); + + it.each(['organization', 'orgs', 'tenants'])('the near-miss `%s` is refused and pointed at `organizations`', (key) => { + const r = SharingConfigSchema.safeParse({ enabled: true, [key]: ['acme'] }); + expect(r.success).toBe(false); + expect(JSON.stringify(r.success ? null : r.error.issues)).toContain('organizations'); + }); +}); + // `EmbedConfigSchema` had a describe block here until #5015 removed the schema // (ADR-0049 enforce-or-remove — no carrier key anywhere, unreachable from every // metadata root, zero parse outside this file; its would-be carrier `App.embed` diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index e5bdd28da98..8fbbde3c463 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -88,6 +88,11 @@ export const SharingConfigSchema = lazySchema(() => strictObject({ domains: 'allowedDomains', allowedDomain: 'allowedDomains', emailDomains: 'allowedDomains', + // The organization binding (ADR-0138 D5). Plural on purpose: one form is + // published to every organization it lists. + organization: 'organizations', + orgs: 'organizations', + tenants: 'organizations', expires: 'expiresAt', expiry: 'expiresAt', expiration: 'expiresAt', @@ -109,6 +114,15 @@ export const SharingConfigSchema = lazySchema(() => strictObject({ .describe('Expiration date/time in ISO 8601 format'), allowAnonymous: z.boolean().optional().default(false) .describe('Allow access without authentication'), + organizations: z.array(z.string().min(1)).min(1).optional().describe( + 'The organizations this public form is published to, by organization slug (ADR-0138 D5). ' + + 'Absent: the form serves the organization the deployment resolves for an anonymous caller, which ' + + 'exists only on a deployment holding exactly one organization, and a link naming an organization ' + + 'is refused. Set: every public link names one of these organizations, as ' + + '`/forms/:slug?organization=`, and a submission lands in that organization, under its own ' + + 'number sequences. A link naming no organization, or one not listed here, is answered as not found ' + + 'by both form doors.', + ), })); // [no-door retirement] `EmbedConfigSchema` / `EmbedConfig` were REMOVED per ADR-0049