Skip to content

Commit 64bbd36

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20234-registry-stage11
2 parents ee14924 + 6dd99b8 commit 64bbd36

18 files changed

Lines changed: 407 additions & 151 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The protocol 17 → 18 conversion summaries no longer cite tracker numbers; each one states the decision behind it in words
6+
7+
Clause-②: no
8+
9+
A conversion's `summary` is the line an author reads when upgrading metadata: `os migrate meta --json` reports it under `specChanges` (its chain already runs to protocol 18), and it becomes the "Change" column of the upgrade guide's protocol 17 → 18 table and the `to` text of `spec-changes.json`'s `converted[]` records once protocol 18 ships. Thirty-five of the protocol-18 summaries pointed at an issue-tracker number for the reason behind a rewrite. The number goes; where the sentence did not already say what was decided, it now does. For example:
10+
11+
- The six duration-key renames (`hook.timeout` → `timeoutMs`, `apis[].cacheTtl` → `cacheTtlSeconds` and the rest) say the rule they follow: a duration key carries its unit in its name.
12+
- `translation-per-app-settings-removed` says why both application doors lose `settings`: settings copy belongs to the platform, and the bundle entry and the translation item are two doors of one type that accept one shape.
13+
- `flow-decision-mode-inclusive-explicit` says the decision node now follows mainstream engines (first match wins) and that taking every true edge must be declared.
14+
- `list-view-sort-string-clause-to-array` and `page-component-filter-record-to-rule-array` say what "one orthography platform-wide" means for each, and why combinator filters are named rather than flattened.
15+
16+
Text only: no conversion's id, surface, protocol step, transform or order changes, and no schema key, shape or default moves. A tool or test that matches the old summary text (for example a tracker-number suffix) needs the new spelling.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/rest': patch
5+
---
6+
7+
Public forms: every declared means of withdrawing a form from anonymous intake is now honoured by every anonymous form door. Which forms a `view` opens to anonymous intake is now decided by one rule, `anonymousFormIntakeCandidates` (new in `@objectstack/metadata-core`, alongside `anonymousFormIntakeSlugs`, `anonymousFormIntakeSlug` and `publicFormSlug`), read by both the anonymous form endpoints in `@objectstack/rest` and the organization-scoped `view` write check in `@objectstack/metadata-protocol`, so the two can no longer disagree. A form is served anonymously only when its `sharing` config declares public sharing as `SharingConfigSchema` defines it: `sharing.enabled: true`, `sharing.allowAnonymous: true` and a `sharing.publicLink` slug. `enabled` defaults to `false`, so a form that set only `allowAnonymous` and `publicLink` is no longer served on the anonymous endpoints (`404 FORM_NOT_FOUND`). Migration: add `enabled: true` to the form's `sharing` block (and to any stored overlay of it) to keep it public; see the public forms guide.

‎content/docs/references/ui/sharing.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ asymmetry survives as the reason this file reads the way it does:
1717

1818
- `SharingConfigSchema` has a **live authoring door**. `FormViewSchema.sharing`
1919
carries it (`view.zod.ts`), `view` is a metadata-type root, and the runtime
20-
really reads it: `rest-server.ts` mounts the anonymous form endpoints only
21-
when `sharing.allowAnonymous === true` and a `sharing.publicLink` slug
22-
matches. Both example apps author it (`app-showcase` `inquiry.view.ts`,
20+
really reads it: `rest-server.ts` serves the anonymous form endpoints only
21+
when `sharing.enabled === true`, `sharing.allowAnonymous === true` and a
22+
`sharing.publicLink` slug matches (`anonymousFormIntakeCandidates` in
23+
`@objectstack/metadata-core`). Both example apps author it (`app-showcase` `inquiry.view.ts`,
2324
`app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14.
2425
- `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) —
2526
see the block below where it stood.

‎content/docs/ui/forms.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Both modes:
2020
- Honor `?prefill_<field>=<value>` URL params
2121
- Honor `submitBehavior` (thank-you / redirect / continue / next-record) — with **mode-aware defaults** when it is omitted (see [§8](#8-submitbehavior--what-happens-after-submit))
2222

23-
A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically.
23+
A **public form** is the Salesforce *Web-to-Lead* style embeddable form — declare a `FormView` with `sharing.enabled: true` and `sharing.allowAnonymous: true`, give it a `publicLink`, and the framework wires the anonymous REST endpoints automatically. Clearing either switch withdraws the form from every anonymous endpoint.
2424

2525
## Architecture at a glance
2626

@@ -86,7 +86,7 @@ export default defineView({
8686
},
8787
],
8888
sharing: {
89-
enabled: true,
89+
enabled: true, // ← required (the schema default is false)
9090
allowAnonymous: true, // ← required
9191
publicLink: '/forms/contact-us', // ← the slug ":contact-us" wires this view to the public route
9292
},
@@ -99,7 +99,7 @@ export default defineView({
9999
> - The slug in `publicLink` (`contact-us`) becomes the `:slug` segment in the REST URL.
100100
> - 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.
101101
> - 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.
102-
> - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed.
102+
> - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed.
103103
104104
## 2. (Optional) Create the `guest_portal` permission set
105105

@@ -230,7 +230,7 @@ Errors:
230230
| `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) |
231231
| `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) |
232232
| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object |
233-
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
233+
| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` |
234234
| `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |
235235

236236
The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` if form resolution itself throws.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SharingConfigSchema } from '@objectstack/spec/ui';
5+
import {
6+
anonymousFormIntakeCandidates,
7+
anonymousFormIntakeSlug,
8+
anonymousFormIntakeSlugs,
9+
publicFormSlug,
10+
} from './anonymous-form-intake.js';
11+
12+
const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };
13+
14+
describe('anonymousFormIntakeSlug — which sharing opens a form to anonymous intake', () => {
15+
it('both switches on and a publicLink: open, slug normalised', () => {
16+
expect(anonymousFormIntakeSlug(OPEN)).toBe('contact-us');
17+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'forms/contact-us' })).toBe('contact-us');
18+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'contact-us' })).toBe('contact-us');
19+
});
20+
21+
it.each<[string, Record<string, unknown>]>([
22+
['enabled: false', { ...OPEN, enabled: false }],
23+
['enabled absent', { allowAnonymous: true, publicLink: '/forms/contact-us' }],
24+
['allowAnonymous: false', { ...OPEN, allowAnonymous: false }],
25+
['allowAnonymous absent', { enabled: true, publicLink: '/forms/contact-us' }],
26+
['publicLink absent', { enabled: true, allowAnonymous: true }],
27+
['publicLink empty', { ...OPEN, publicLink: '' }],
28+
['a truthy non-boolean switch', { ...OPEN, enabled: 'true' }],
29+
])('%s: closed', (_label, sharing) => {
30+
expect(anonymousFormIntakeSlug(sharing)).toBeNull();
31+
});
32+
33+
it('a raw body and its parse get the same answer (the schema defaults `enabled` to false)', () => {
34+
for (const raw of [OPEN, { allowAnonymous: true, publicLink: '/forms/contact-us' }, { ...OPEN, enabled: false }]) {
35+
expect(anonymousFormIntakeSlug(SharingConfigSchema.parse(raw))).toBe(anonymousFormIntakeSlug(raw));
36+
}
37+
});
38+
39+
it('not an object: closed', () => {
40+
expect(anonymousFormIntakeSlug(undefined)).toBeNull();
41+
expect(anonymousFormIntakeSlug(null)).toBeNull();
42+
expect(anonymousFormIntakeSlug('x')).toBeNull();
43+
});
44+
});
45+
46+
describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs — the three form shapes of a view', () => {
47+
const view = (sharing: Record<string, unknown>) => ({
48+
name: 'inquiry.contact',
49+
object: 'inquiry',
50+
form: { data: { object: 'inquiry' }, sharing: { ...sharing, publicLink: '/forms/nested' } },
51+
formViews: {
52+
a: { sharing: { ...sharing, publicLink: '/forms/a' } },
53+
b: { sharing: { ...OPEN, enabled: false, publicLink: '/forms/b' } },
54+
},
55+
viewKind: 'form',
56+
config: { sharing: { ...sharing, publicLink: 'forms/flat' } },
57+
});
58+
59+
it('scans the nested form, every formViews entry and the flattened config, open ones only', () => {
60+
const c = anonymousFormIntakeCandidates(view(OPEN));
61+
expect(c.map((x) => [x.key, x.slug])).toEqual([
62+
[undefined, 'nested'],
63+
['a', 'a'],
64+
['inquiry.contact', 'flat'],
65+
]);
66+
expect(anonymousFormIntakeSlugs(view(OPEN))).toEqual(['a', 'flat', 'nested']);
67+
});
68+
69+
it('withdrawn through either switch: no candidate on any shape', () => {
70+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, enabled: false }))).toEqual([]);
71+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual([]);
72+
});
73+
74+
it('de-duplicates and sorts slugs; tolerates non-object input', () => {
75+
expect(anonymousFormIntakeSlugs({ formViews: { x: { sharing: OPEN }, y: { sharing: { ...OPEN, publicLink: 'contact-us' } } } }))
76+
.toEqual(['contact-us']);
77+
expect(anonymousFormIntakeSlugs(null)).toEqual([]);
78+
expect(anonymousFormIntakeSlugs({ formViews: { x: null } })).toEqual([]);
79+
expect(publicFormSlug('//forms/x')).toBe('x');
80+
});
81+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which forms a `view` body opens to anonymous intake — the ONE rule.
5+
*
6+
* The anonymous form doors (`GET /forms/:slug`, `POST /forms/:slug/submit`,
7+
* `registerFormEndpoints` in `@objectstack/rest`) serve exactly the candidates
8+
* this module returns, and `@objectstack/metadata-protocol` judges an
9+
* organization-scoped `view` write by the slug set it projects. Both import it
10+
* from here so the doors and the write-time judgement can never disagree about
11+
* which forms are published.
12+
*
13+
* A form candidate is open to anonymous intake when its `sharing` (the spec's
14+
* `SharingConfigSchema`) declares all three of:
15+
*
16+
* - `enabled === true` — "Enable public sharing". The schema defaults it to
17+
* `false`, and a parsed body carries that default, so an absent `enabled`
18+
* reads as not shared here too: a raw body and its parsed form get the same
19+
* answer.
20+
* - `allowAnonymous === true` — "Allow access without authentication".
21+
* - a non-empty `publicLink` naming the slug.
22+
*
23+
* Clearing either switch withdraws the form from every anonymous door.
24+
*
25+
* The candidates are the three shapes a view carries a form in: the nested
26+
* `form`, every `formViews` entry, and the flattened `config` of a
27+
* `viewKind: 'form'` item.
28+
*/
29+
30+
/** A form candidate of a view that is open to anonymous intake. */
31+
export interface AnonymousFormIntakeCandidate {
32+
/** The form view object (the nested `form`, a `formViews` entry, or the flattened `config`). */
33+
form: Record<string, any>;
34+
/** The `formViews` key, or the view name for a flattened `viewKind: 'form'` item. */
35+
key?: string;
36+
/** The slug its `publicLink` names, normalised (`/forms/x`, `forms/x` and `x` are one slug). */
37+
slug: string;
38+
}
39+
40+
/** Normalise a `publicLink` to the slug the doors compare: `/forms/x`, `forms/x` and `x` are one slug. */
41+
export function publicFormSlug(publicLink: string): string {
42+
return publicLink.replace(/^\/+/, '').replace(/^forms\//, '');
43+
}
44+
45+
/** The slug a form's `sharing` opens to anonymous intake, or `null` when it opens none. */
46+
export function anonymousFormIntakeSlug(sharing: unknown): string | null {
47+
if (!sharing || typeof sharing !== 'object') return null;
48+
const s = sharing as Record<string, unknown>;
49+
if (s.enabled !== true) return null;
50+
if (s.allowAnonymous !== true) return null;
51+
if (typeof s.publicLink !== 'string' || !s.publicLink) return null;
52+
return publicFormSlug(s.publicLink);
53+
}
54+
55+
/** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */
56+
export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] {
57+
if (!view || typeof view !== 'object') return [];
58+
const v = view as Record<string, any>;
59+
const forms: Array<{ form: unknown; key?: string }> = [];
60+
if (v.form && typeof v.form === 'object') forms.push({ form: v.form });
61+
if (v.formViews && typeof v.formViews === 'object') {
62+
for (const [key, fv] of Object.entries(v.formViews)) forms.push({ form: fv, key });
63+
}
64+
if (v.viewKind === 'form' && v.config && typeof v.config === 'object') {
65+
forms.push({ form: v.config, key: v.name });
66+
}
67+
const open: AnonymousFormIntakeCandidate[] = [];
68+
for (const { form, key } of forms) {
69+
if (!form || typeof form !== 'object') continue;
70+
const slug = anonymousFormIntakeSlug((form as Record<string, unknown>).sharing);
71+
if (slug === null) continue;
72+
open.push({ form: form as Record<string, any>, ...(key !== undefined ? { key } : {}), slug });
73+
}
74+
return open;
75+
}
76+
77+
/**
78+
* The sorted, de-duplicated slug set a `view` body opens to anonymous intake.
79+
* Two bodies with the same set open exactly the same anonymous doors.
80+
*/
81+
export function anonymousFormIntakeSlugs(view: unknown): string[] {
82+
return [...new Set(anonymousFormIntakeCandidates(view).map((c) => c.slug))].sort();
83+
}

‎packages/metadata-core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,9 @@ export * from './record-organization.js';
137137
// metadata-protocol, and a boot log with its own opinion about which
138138
// declarations the registry will take is the very defect this card closes.
139139
export * from './object-field-type.js';
140+
141+
// Which forms a `view` body opens to anonymous intake. The enforcing doors live
142+
// in `@objectstack/rest` and the write-time judgement of an organization-scoped
143+
// `view` write in `@objectstack/metadata-protocol`; both read this one rule, so
144+
// a form withdrawn by either declared switch is withdrawn everywhere.
145+
export * from './anonymous-form-intake.js';

‎packages/metadata-protocol/src/anonymous-form-intake.ts‎

Lines changed: 0 additions & 37 deletions
This file was deleted.

‎packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,18 @@ describe('org-scoped anonymous form intake changes the anonymous doors cannot se
604604
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
605605
});
606606

607+
it('walled: an org-scoped withdrawal through `sharing.enabled` alone is refused the same way', async () => {
608+
const { protocol, rows } = makeTenancyProtocol(null);
609+
await publishEnvWide(protocol);
610+
const body = FORM_VIEW(true);
611+
body.config.sharing.enabled = false;
612+
613+
await expect(protocol.saveMetaItem({
614+
type: 'view', name: 'task.intake_form', item: body, organizationId: 'org_a',
615+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
616+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
617+
});
618+
607619
it('walled: an org-scoped draft of the withdrawal is refused too', async () => {
608620
const { protocol, rows } = makeTenancyProtocol(null);
609621
await publishEnvWide(protocol);

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import {
3838
// [#7560] ADR-0070's read-only-package rule, shared with the `/packages`
3939
// lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`.
4040
import { isWritablePackage as isWritablePackageShared } from './package-writability.js';
41-
import { anonymousFormIntakeSlugs } from './anonymous-form-intake.js';
4241
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
4342
// [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the
4443
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
@@ -90,6 +89,9 @@ import {
9089
// {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer
9190
// fallback so a hydrated row is never answered as the code layer.
9291
isTenantAuthored,
92+
// The one rule for which forms a `view` body opens to anonymous intake —
93+
// the same rule the anonymous form doors in `@objectstack/rest` serve by.
94+
anonymousFormIntakeSlugs,
9395
} from '@objectstack/metadata-core';
9496
// [#5532] One vocabulary of "which driver read errors are benign", shared with
9597
// `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in

0 commit comments

Comments
 (0)