Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/21476-public-form-intake-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/metadata-core': minor
'@objectstack/metadata-protocol': patch
'@objectstack/rest': patch
---

Public forms on a walled tenancy posture: saving or publishing a view whose public form cannot take anonymous intake now tells the author why, on the response.

Clause-②: yes (widening)

On a walled posture (`group` or `isolated` in force), an open public form whose object is walled by an organization column cannot take an anonymous submission: the submission carries no organization, and an insert without one into a walled object is refused. The two anonymous form endpoints already answer such a form as a withdrawn one (`404 FORM_NOT_FOUND`), and the administrator's read of the view (`GET /meta/view/:name`) already states why in `_diagnostics.warnings`.

- **`@objectstack/metadata-protocol`**: saving the view (`PUT /meta/view/:name`) or publishing its draft (`POST /meta/view/:name/publish`, and a package's batch publish) now answers success with one `warning` advisory per such form, under `advisories`, with rule `public-form-intake-unavailable`. It is located at the form's `sharing` (for example `views[0].formViews.contact.sharing`), its `message` is the same text the administrator's read states, and its `hint` is the remedy: if the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it. The write is never refused. The advisory reads the posture in force from the `tenancy` service, which is what the anonymous endpoints read: a single-posture deployment, a deployment whose walled posture is degraded to `single`, a deployment with no tenancy service, and a form bound to a tenancy-disabled object get no advisory, and a draft save is not judged. The publish refusal for an unstamped platform schedule flow still reads the requested posture, as before.
- **`@objectstack/metadata-core`**: the intake-availability rule moved here from `@objectstack/rest` and is exported, so the anonymous endpoints, the administrator's read and the publish advisory read one answer: `anonymousFormIntakeUnavailability(object, posture, readObjectSchema)` (`null` when the form can take intake, otherwise the object, the posture and the wall column; it judges the object's effective schema, with the injected `organization_id`), `anonymousFormIntakePosture(tenancy)` (the posture in force, as a tenancy service reports it), `anonymousFormIntakeUnavailableMessage` and `anonymousFormIntakeUnavailableRemedy` (the reason and its remedy), `anonymousFormSharingPath` and `anonymousFormObjectName`, and the type `AnonymousFormIntakeUnavailable`.
- **`@objectstack/rest`**: the anonymous form endpoints and the administrator's read import that rule instead of holding their own copy. Their answers are unchanged.
17 changes: 12 additions & 5 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ |
| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698), row-level-security predicates (#4983) | ✓ | ✓ | ✓ | — |
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ |
Expand Down Expand Up @@ -594,11 +595,17 @@ The fourth door does not weaken that, because it is held to the CLI's verdicts
rather than to its own: a test fails if a rule runs at the runtime publish gate
but not on `os build` — the two publish verbs must not disagree. What that
column narrows is which *types* it judges, never which *verdict* it reaches. The
one deliberate exception is the platform-schedule row (#6285), runtime-only by ruling:
both of its inputs are facts about the **deployment** (the organization this
write lands in, and whether this deployment walls organizations), and a build
machine's environment is a false signal for them — so `os build` must not judge
it at all.
deliberate exceptions are the two rows whose inputs are facts about the
**deployment**, and a build machine's environment is a false signal for those —
so `os build` must not judge them at all. The platform-schedule row (#6285) is
runtime-only by ruling: its inputs are the organization this write lands in and
whether this deployment walls organizations. The public-form intake row (#21476)
reads the tenancy posture **in force**: on a walled posture, an open public form
whose object is walled by an organization column cannot take an anonymous
submission, so the anonymous form endpoints do not offer it, and a save or
publish of the view answers success with a `public-form-intake-unavailable`
warning in `advisories`, located at the form's `sharing`. It never refuses the
write.

Some rows are deliberately not universal across the three commands, and each is
one-directional (none lets a stack through a gate another command enforces):
Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ 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.

## 2. (Optional) Create the `guest_portal` permission set

Expand Down
105 changes: 105 additions & 0 deletions packages/metadata-core/src/anonymous-form-intake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import { describe, it, expect } from 'vitest';
import { SharingConfigSchema } from '@objectstack/spec/ui';
import {
anonymousFormIntakeCandidates,
anonymousFormIntakePosture,
anonymousFormIntakeSlug,
anonymousFormIntakeSlugs,
anonymousFormIntakeUnavailability,
anonymousFormIntakeUnavailableMessage,
anonymousFormIntakeUnavailableRemedy,
anonymousFormObjectName,
anonymousFormSharingPath,
publicFormSlug,
} from './anonymous-form-intake.js';

Expand Down Expand Up @@ -79,3 +85,102 @@ describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs — the three
expect(publicFormSlug('//forms/x')).toBe('x');
});
});

// [#21476] Whether an open form can take an anonymous submission on this
// posture — the one predicate both anonymous doors, the admin read and the
// runtime authoring gate's advisory read.
describe('anonymousFormIntakeUnavailability — the intake-availability predicate', () => {
/** The object as a served document carries it: the registry injects `organization_id`. */
const served = (extra: Record<string, unknown> = {}) => ({
name: 'inquiry',
fields: { organization_id: { type: 'lookup', reference: 'sys_organization' }, email: { type: 'text' } },
...extra,
});
/** The same object as a stored or pending body carries it: declared fields only. */
const raw = (extra: Record<string, unknown> = {}) => ({ 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()))
.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()))
.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');
});

it.each<[string, unknown]>([
['tenancy: { enabled: false } (ADR-0066)', served({ tenancy: { enabled: false } })],
['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();
});

it.each<[string, 'single' | undefined]>([
["the 'single' posture", 'single'],
['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(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());
expect(walled).toBeInstanceOf(Promise);
expect(await walled).toEqual({ object: 'inquiry', posture: 'group', tenantField: 'organization_id' });
expect(anonymousFormIntakeUnavailability('inquiry', 'single', async () => served())).toBeNull();
});
});

describe('anonymousFormIntakePosture — the posture IN FORCE, as the tenancy service reports it', () => {
it('reads `posture`, never `requestedPosture`: a degraded walled request is single', () => {
expect(anonymousFormIntakePosture({ posture: 'single', requestedPosture: 'isolated' })).toBe('single');
expect(anonymousFormIntakePosture({ posture: 'group' })).toBe('group');
expect(anonymousFormIntakePosture({ posture: 'multi' })).toBe('isolated');
});

it('no service, or no recognisable posture: undefined', () => {
for (const tenancy of [undefined, null, {}, { posture: 'walled' }, 'isolated']) {
expect(anonymousFormIntakePosture(tenancy)).toBeUndefined();
}
});
});

describe('where the reason is located, and the reason itself', () => {
it('anonymousFormSharingPath: form.sharing, formViews.KEY.sharing, config.sharing', () => {
const view = {
name: 'inquiry.contact',
form: { sharing: { ...OPEN, publicLink: '/forms/nested' } },
formViews: { contact: { sharing: { ...OPEN, publicLink: '/forms/a' } } },
viewKind: 'form',
config: { sharing: { ...OPEN, publicLink: '/forms/flat' } },
};
expect(anonymousFormIntakeCandidates(view).map((c) => anonymousFormSharingPath(view, c)))
.toEqual(['form.sharing', 'formViews.contact.sharing', 'config.sharing']);
});

it('anonymousFormObjectName: the form\'s own data.object first, then the view\'s', () => {
const view = { object: 'v_obj', list: { data: { object: 'list_obj' } } };
expect(anonymousFormObjectName(view, { data: { object: 'form_obj' } })).toBe('form_obj');
expect(anonymousFormObjectName(view, {})).toBe('list_obj');
expect(anonymousFormObjectName({ object: 'v_obj' }, {})).toBe('v_obj');
expect(anonymousFormObjectName(undefined, undefined)).toBeUndefined();
});

it('the message names the slug, the object, the column and the posture, and ends with the remedy', () => {
const u = { object: 'inquiry', posture: 'isolated' as const, tenantField: 'organization_id' };
const message = anonymousFormIntakeUnavailableMessage('contact-us', u);
for (const named of ["'/forms/contact-us'", "'inquiry'", "'organization_id'", "'isolated'"]) {
expect(message).toContain(named);
}
expect(anonymousFormIntakeUnavailableRemedy(u)).toContain('tenancy: { enabled: false }');
expect(message.endsWith(` ${anonymousFormIntakeUnavailableRemedy(u)}`)).toBe(true);
});
});
Loading
Loading