Skip to content
30 changes: 30 additions & 0 deletions .changeset/25574-public-form-organization.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions content/docs/references/ui/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slug>`, 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. |


---
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slug>`, 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']`

Expand Down
22 changes: 20 additions & 2 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/security/public-form-organization.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = { 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);
});
});
93 changes: 93 additions & 0 deletions packages/core/src/security/public-form-organization.ts
Original file line number Diff line number Diff line change
@@ -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<readonly string[]>;
}): Promise<PublicFormOrganizationDecision> {
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] };
}
Loading
Loading