Skip to content

Commit 56c8844

Browse files
feat(spec): export the anonymous-form-intake candidates from @objectstack/spec/ui; metadata-core re-exports the same bindings (#22056)
Fixes #22047 Clause-②: yes (widening: new exports on the published `@objectstack/spec/ui` entry; `@objectstack/spec` changeset at least `minor`) ## What changes - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. This is the candidates half of the anonymous-form-intake rule, which the triage ruling on objectstack-ai/objectui#11545 (`5967405932`) asks a console to read instead of re-deriving "published" from the sharing keys. - **Moved, not copied.** The bodies are the ones that were in `packages/metadata-core/src/anonymous-form-intake.ts`, unchanged except for indentation (2 spaces, as in the rest of `packages/spec`). `diff -w` between the BASE lines 52-105 and the new module is empty. The scan still covers the three shapes in the same order: nested `form`, then `formViews` entries, then a `viewKind: 'form'` item's `config`. - **`@objectstack/metadata-core` re-exports the same bindings** (`export { … } from '@objectstack/spec/ui'`, plus `export type` for the interface), so one copy remains. Its remaining code imports `publicFormSlug` and the type from the spec. Its posture, withdrawal-layer and object-name parts stay where they were. `@objectstack/rest` and `@objectstack/metadata-protocol` are not edited and keep importing from `metadata-core`. `packages/metadata-core/src/index.ts` is unchanged. - Changeset `.changeset/22047-spec-ui-anonymous-form-intake.md`: `@objectstack/spec` `minor`, `@objectstack/metadata-core` `patch` (its built `dist/index.{js,cjs,d.ts,d.cts}` now import these functions from `@objectstack/spec/ui` instead of defining them). ## Where it lives, and why (H3) It goes in a new module, `packages/spec/src/ui/anonymous-form-intake.ts`, next to `sharing.zod.ts`. It is not added to `sharing.zod.ts`, for three reasons: - **The module imports nothing.** It has no zod import and no schema import, and does no work at load time. It stays as cheap as a browser can import, whichever entry reaches it. `sharing.zod.ts` imports `zod` and two schema helpers. - **This is the existing pattern for runtime helpers in `spec/ui` that do not live inside a schema module.** `chart-aggregate.ts`, `i18n-label-resolver.ts` and `view-grouping-query.ts` are sibling non-`.zod.ts` modules. `expandViewContainer` sits in `view.zod.ts` only because it reads that module's member constants. These four functions read no schema. - **The file name matches the one in `metadata-core`,** so the move is easy to follow in history. Trade-off: `files[]` ships `src/**/*.zod.ts` as source, so this module's source is not in the tarball. Its JS and declarations are, in `dist/ui/index.{mjs,js,d.mts,d.ts}`. Prime Directive 2 (no business logic in `packages/spec`: schemas, types and constants only) holds here the way ADR-0053 D-D2 reads it. A pure helper that states what the contract's own vocabulary denotes is protocol, not business logic. It lives beside that vocabulary, and a server package re-exports it: D-D2 moved `nextUtcCalendarDay` into `@objectstack/spec/data` and has `@objectstack/core` re-export it. These four functions only say which `sharing` declarations open a form. The two checks that read server state (another layer's withdrawal, the tenancy posture) stay in `metadata-core`. So does `anonymousFormObjectName`, a pure read of the form and the view, because that is where this export's surface was drawn. `expandViewContainer` (`view.zod.ts`) is the placement precedent: a pure helper beside the schema it serves. The reason two codebases must agree on this rule byte for byte is the triage ruling on objectui#11545 (`5967405932`), as the module's header now says (patch round 1, `6c5741c6`). ## Pins and measurements **Identity pin (3).** This is in `packages/metadata-core/src/anonymous-form-intake.test.ts`; the existing cases are unchanged, and the pin adds 3 import lines and 1 `describe`. For each of the four names it asserts that `./anonymous-form-intake.js[name]` and `./index.js[name]` are `toBe` (Object.is) `@objectstack/spec/ui[name]`. - **Ablation:** run once and not kept, through `scripts/ablation-replace.mjs` with the fix committed first. The re-export of `anonymousFormIntakeCandidates` was replaced by a wrapper that returns the spec function's answer. - Result: `Tests 1 failed | 45 passed (46)`. Only the identity case for `anonymousFormIntakeCandidates` failed (`expected [Function] to be [Function] // Object.is equality`). Every behaviour test passed against the wrapper, so only the identity pin can catch a copy. - Restore was verified by the tool: the blob equals HEAD (`c08671875`) and `git diff HEAD` is empty. metadata-core's test reads its own `src` directly, so the mutation needed no rebuild to take effect. **H5: identity in the built dual output.** A one-time node probe, run from `packages/rest`, compared the four functions in metadata-core's built output with `@objectstack/spec/ui`'s: | condition | metadata-core export === spec/ui export | |---|---| | ESM (`dist/index.js` vs `dist/ui/index.mjs`) | true for all four | | CJS (`dist/index.cjs` vs `dist/ui/index.js`) | true for all four | | ESM vs CJS (cross-condition) | false: the dual-package split every spec export already has | **Parity pin (1).** `packages/spec/src/ui/anonymous-form-intake.test.ts` (26 cases) pins each of the three shapes on its own (open; withdrawn by either switch; no link), all three in one body in scan order, a `config` without `viewKind: 'form'`, slug normalisation, and raw input against `SharingConfigSchema.parse` input. Its expectations are the same as metadata-core's existing ones. A one-time parity probe compared the BASE metadata-core functions (`git show 8caa131`, lines 52-105) with the built spec/ui and metadata-core functions now. It compared candidate keys, key presence, slugs, whether each candidate is a form object from the input, and the slug set: | shape | bodies | with an open slug | BASE = spec/ui = metadata-core | |---|---|---|---| | nested `form` | 13 | 4 | 13 | | `formViews` entry (plus an open sibling) | 13 | 13 | 13 | | `viewKind: 'form'` + `config` | 13 | 4 | 13 | | all three in one body | 13 | 4 | 13 | | `config` without `viewKind: 'form'` | 13 | 0 | 13 | | non-view input | 4 | 0 | 4 | | real producers: showcase `inquiry.view.ts`, crm `lead.view.ts`, as containers and as `expandViewContainer` items | 9 | 4 | 9 | That is 78 bodies with 0 mismatches, and 21 leaf inputs (`anonymousFormIntakeSlug`, `publicFormSlug`) with 0 mismatches. **H1.** Lines 52-105 call nothing from `@objectstack/spec/security`, `applyInjectedSystemColumns` or `resolveRecordWallOrganizationField`. The new module has no imports, and the four bodies compile unchanged in `packages/spec`. **H2.** `./ui` is in `browser-reachable-entries.json`'s `unjudged` list, so `check:browser-reachable-entries` asserts nothing about it (it passed). The module is plain `function` declarations with no top-level statements, and the package declares `"sideEffects": false`. **Declaration surface downstream.** `metadata-core`'s `dist/index.d.ts` and `.d.cts` now reference `@objectstack/spec/ui`. Measured with `tsc --noEmit --extendedDiagnostics --listFiles`, building metadata-core from BASE source and then from HEAD source. These are absolute numbers from a shared box: | program | files BASE → HEAD | memory BASE → HEAD | |---|---|---| | `packages/rest` | 579 → 580 | 1,246,583K → 1,268,472K | | `packages/objectql` | 574 → 574 | 983,063K → 994,921K | | `packages/plugins/plugin-security` | 495 → 495 | 1,079,234K → 1,084,561K | | `packages/metadata-protocol` | 808 → 808 | 1,319,331K → 1,318,969K | | `packages/qa/http-conformance` | 345 → 345 | 357,199K → 357,044K | The one new file in `rest` is `spec/dist/ui/index.d.ts`, the CJS barrel, reached through `metadata-core/dist/index.d.cts`. The chunks it re-exports were already in that program in both flavours. tsc exited 0 in every program on both builds. ## Prose that named the old home (H4) - `packages/spec/src/ui/sharing.zod.ts:19-23` said the rule lives in `anonymousFormIntakeCandidates` "in `@objectstack/metadata-core`". It now names `anonymous-form-intake.ts` beside that module, which metadata-core re-exports to the server's doors. - `content/docs/references/ui/sharing.mdx:22-26` was regenerated from that docblock by `gen:docs`, not edited by hand. - `packages/metadata-core/src/anonymous-form-intake.ts:13-20`: the module docblock says the candidates half is declared in `@objectstack/spec/ui`, whose docblock is now the authority on it. The scan-shape paragraph moved with the code. - Judged still true and left alone: - `packages/rest/src/rest-server.ts:10698` reads "(`anonymousFormIntakeCandidates`, `@objectstack/metadata-core`)". That is where rest imports the function from, and metadata-core still exports it. The file is also outside this card's surface. - `packages/metadata-core/src/index.ts:141-145` ("both read this one rule"). - The `docs/qa/platform-checklist` mechanism references name `anonymousFormIntakeWithdrawnIn` and `anonymousFormExplicitWithdrawals`, which stay in metadata-core. ## Verification (HEAD `cfdc8804f0`; the source tree is the same as `3e9a9e48b1` plus the changeset) - `pnpm --filter @objectstack/spec build` (JS + DTS): exit 0. `check-dts-emitted` reported 38/38. - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date. Before regeneration, 3 were stale: `api-surface/` (+5 rows in `ui.json`), `export-origins/` (+5) and `content/docs/references/**` (the H4 sentence). They were regenerated with `gen:api-surface`, `gen:export-origins` and `gen:docs`. - `check:api-surface`, `check:export-origins`, `check:docs`, `check:exported-any`, `check:dual-source-exports` (0 accepted dual-source), `check:entry-nameability`, `check:browser-reachable-entries`, `check:liveness`, `check:llms-txt`, `check:skill-examples`: exit 0. - `pnpm --filter @objectstack/spec test`: 620 files, 18511 passed, 1 todo. The new file alone: 26 passed. - `pnpm --filter @objectstack/metadata-core test`: 18 files, 415 passed. `src/anonymous-form-intake.test.ts` alone: 46 passed. - `pnpm --filter @objectstack/metadata-protocol test`: 219 files passed, 3 skipped; 28135 tests passed. The focused run of `runtime-authoring-gate.public-form-intake`, `protocol.runtime-authoring-gate` and `protocol.org-scoped-write-refused` passed 257. - `pnpm --filter @objectstack/rest test`: 260 files, 4914 passed, 326 skipped. The focused run of `public-form-routes`, `public-form-routes.stored-row`, `public-form-withdrawal` and `public-form-intake-availability` passed 76. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/metadata-core typecheck`: exit 0. `--listFiles` shows both new test files are in their packages' test programs. - Gates derived by `node scripts/pm/dispatch-gates.mjs --commands` at `cfdc8804f0`, and checked with `--ran`: 110 derived, 109 run, 0 unrun, 1 NOT MEASURED. The NOT MEASURED one is `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it needs every package built. The CJS half of the H5 probe above loaded `metadata-core/dist/index.cjs` with `require`. The 5 roster gates the lead list flagged under a touched directory also pass (`check:meta-url-spelling` and `check:spec-changes` through `check:generated`; `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). - Lint is a narrowed run, and it measures something: `eslint --no-inline-config --format json` over the 6 changed TS files reported 6 files, 0 errors and 0 warnings. All 6 are in the population of `eslint.config.mjs` (`files: ['**/*.{ts,…}']` minus `NEVER_LINTED`, and `--print-config` resolves each one). That config never turns on type-aware linting (no `parserOptions.project`, no typed rules; see its own note near line 327), so this diff cannot change the result for any file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **Not covered by this card** (the card's "Not this card" section): whether another layer withdraws a form, whether the posture makes a form unavailable, any server-side "published" answer, and the objectui page change. objectui#11545 restarts once objectui uses a release that carries these exports. - `main` gained #22021 (a spec analytics change) after this branch was cut. It touches none of these files and none of the generated artifacts, so `main` is not merged here, and the merge queue rebuilds on the current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2015c54 commit 56c8844

10 files changed

Lines changed: 336 additions & 73 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': patch
4+
---
5+
6+
`@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve
7+
8+
Clause-②: yes (widening)
9+
10+
- **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read.
11+
- **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item.
12+
- **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them.
13+
- **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture.

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ asymmetry survives as the reason this file reads the way it does:
2020
really reads it: `rest-server.ts` serves the anonymous form endpoints only
2121
when `sharing.enabled === true`, `sharing.allowAnonymous === true` and a
2222
`sharing.publicLink` slug matches (`anonymousFormIntakeCandidates` in
23-
`@objectstack/metadata-core`). Both example apps author it (`app-showcase` `inquiry.view.ts`,
24-
`app-crm` `lead.view.ts`). It is `strictObject` as of #4001 批 14.
23+
`anonymous-form-intake.ts` beside this module, which
24+
`@objectstack/metadata-core` re-exports to the server's doors). Both
25+
example apps author it (`app-showcase` `inquiry.view.ts`, `app-crm`
26+
`lead.view.ts`). It is `strictObject` as of #4001 批 14.
2527
- `EmbedConfigSchema` was **REMOVED** at #5015 (ADR-0049 enforce-or-remove) —
2628
see the block below where it stood.
2729

‎packages/metadata-core/src/anonymous-form-intake.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import * as specUi from '@objectstack/spec/ui';
45
import { SharingConfigSchema } from '@objectstack/spec/ui';
56
import {
67
anonymousFormIntakeCandidates,
@@ -15,6 +16,8 @@ import {
1516
anonymousFormSharingPath,
1617
publicFormSlug,
1718
} from './anonymous-form-intake.js';
19+
import * as intakeModule from './anonymous-form-intake.js';
20+
import * as metadataCore from './index.js';
1821

1922
const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };
2023

@@ -315,3 +318,21 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same
315318
});
316319
});
317320
});
321+
322+
// The candidates half is declared in `@objectstack/spec/ui` and re-exported by
323+
// this package. "One copy" is checkable only as IDENTITY: a wrapper or a copy
324+
// answers the same today and drifts tomorrow, while the same binding cannot.
325+
describe('the candidates half is the spec binding itself, re-exported (one copy, not a copy)', () => {
326+
const NAMES = [
327+
'publicFormSlug',
328+
'anonymousFormIntakeSlug',
329+
'anonymousFormIntakeCandidates',
330+
'anonymousFormIntakeSlugs',
331+
] as const;
332+
333+
it.each(NAMES)('%s: this module and the package barrel export the @objectstack/spec/ui function', (name) => {
334+
expect(typeof specUi[name]).toBe('function');
335+
expect(intakeModule[name]).toBe(specUi[name]);
336+
expect(metadataCore[name]).toBe(specUi[name]);
337+
});
338+
});

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

Lines changed: 22 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,20 @@
1010
* from here so the doors and the write-time judgement can never disagree about
1111
* which forms are published.
1212
*
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.
13+
* The candidates half — which `sharing` opens a form (`enabled === true`,
14+
* `allowAnonymous === true` and a non-empty `publicLink` naming the slug) and
15+
* the three shapes a view carries a form in — is declared in
16+
* `@objectstack/spec/ui` (`anonymous-form-intake.ts`, beside the
17+
* `SharingConfigSchema` it reads), whose docblock is the authority on it. This
18+
* module re-exports those bindings unchanged, so a console that imports them
19+
* from the spec reads the same rule the doors serve. Clearing either switch
20+
* withdraws the form from every anonymous door.
2421
*
2522
* A withdrawal is a kill switch: any metadata layer whose body of the same
2623
* view name explicitly withdraws the form (the link kept, a switch set to
2724
* `false`), matched by slot or by slug, closes it, and layering may only narrow
2825
* intake, never re-open it ({@link anonymousFormIntakeWithdrawnIn}).
2926
*
30-
* The candidates are the three shapes a view carries a form in: the nested
31-
* `form`, every `formViews` entry, and the flattened `config` of a
32-
* `viewKind: 'form'` item.
33-
*
3427
* [#21476] The module also answers the second question an open form raises:
3528
* can it take an anonymous submission on THIS deployment's posture
3629
* ({@link anonymousFormIntakeUnavailability})? Three readers ask it — both
@@ -46,63 +39,23 @@ import {
4639
postureEnforcesWall,
4740
type TenancyPosture,
4841
} from '@objectstack/spec/security';
42+
import { publicFormSlug, type AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';
4943
import { applyInjectedSystemColumns } from './injected-system-columns.js';
5044
import { resolveRecordWallOrganizationField } from './record-organization.js';
5145

52-
/** A form candidate of a view that is open to anonymous intake. */
53-
export interface AnonymousFormIntakeCandidate {
54-
/** The form view object (the nested `form`, a `formViews` entry, or the flattened `config`). */
55-
form: Record<string, any>;
56-
/** The `formViews` key, or the view name for a flattened `viewKind: 'form'` item. */
57-
key?: string;
58-
/** The slug its `publicLink` names, normalised (`/forms/x`, `forms/x` and `x` are one slug). */
59-
slug: string;
60-
}
61-
62-
/** Normalise a `publicLink` to the slug the doors compare: `/forms/x`, `forms/x` and `x` are one slug. */
63-
export function publicFormSlug(publicLink: string): string {
64-
return publicLink.replace(/^\/+/, '').replace(/^forms\//, '');
65-
}
66-
67-
/** The slug a form's `sharing` opens to anonymous intake, or `null` when it opens none. */
68-
export function anonymousFormIntakeSlug(sharing: unknown): string | null {
69-
if (!sharing || typeof sharing !== 'object') return null;
70-
const s = sharing as Record<string, unknown>;
71-
if (s.enabled !== true) return null;
72-
if (s.allowAnonymous !== true) return null;
73-
if (typeof s.publicLink !== 'string' || !s.publicLink) return null;
74-
return publicFormSlug(s.publicLink);
75-
}
76-
77-
/** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */
78-
export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] {
79-
if (!view || typeof view !== 'object') return [];
80-
const v = view as Record<string, any>;
81-
const forms: Array<{ form: unknown; key?: string }> = [];
82-
if (v.form && typeof v.form === 'object') forms.push({ form: v.form });
83-
if (v.formViews && typeof v.formViews === 'object') {
84-
for (const [key, fv] of Object.entries(v.formViews)) forms.push({ form: fv, key });
85-
}
86-
if (v.viewKind === 'form' && v.config && typeof v.config === 'object') {
87-
forms.push({ form: v.config, key: v.name });
88-
}
89-
const open: AnonymousFormIntakeCandidate[] = [];
90-
for (const { form, key } of forms) {
91-
if (!form || typeof form !== 'object') continue;
92-
const slug = anonymousFormIntakeSlug((form as Record<string, unknown>).sharing);
93-
if (slug === null) continue;
94-
open.push({ form: form as Record<string, any>, ...(key !== undefined ? { key } : {}), slug });
95-
}
96-
return open;
97-
}
98-
99-
/**
100-
* The sorted, de-duplicated slug set a `view` body opens to anonymous intake.
101-
* Two bodies with the same set open exactly the same anonymous doors.
102-
*/
103-
export function anonymousFormIntakeSlugs(view: unknown): string[] {
104-
return [...new Set(anonymousFormIntakeCandidates(view).map((c) => c.slug))].sort();
105-
}
46+
// The candidates half of the rule — which form candidates a `view` body opens
47+
// to anonymous intake — lives in `@objectstack/spec/ui` beside the
48+
// `SharingConfigSchema` it reads, so a console can import the same rule the
49+
// doors serve. Re-exported here as the SAME bindings (never a wrapper or a
50+
// copy), so every server caller keeps importing it from this package and the
51+
// two entries cannot disagree; the identity is pinned in this module's test.
52+
export {
53+
anonymousFormIntakeCandidates,
54+
anonymousFormIntakeSlug,
55+
anonymousFormIntakeSlugs,
56+
publicFormSlug,
57+
} from '@objectstack/spec/ui';
58+
export type { AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';
10659

10760
/** The object an open form candidate submits into: the form's own `data.object`, else the view's. */
10861
export function anonymousFormObjectName(view: unknown, form: unknown): string | undefined {

‎packages/spec/api-surface/ui.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"AddRecordConfig (type)",
4444
"AddRecordConfigParsed (type)",
4545
"AddRecordConfigSchema (const)",
46+
"AnonymousFormIntakeCandidate (interface)",
4647
"App (const)",
4748
"App (type)",
4849
"AppBranding (type)",
@@ -475,6 +476,9 @@
475476
"WidgetColorVariant (type)",
476477
"WidgetColorVariantSchema (const)",
477478
"actionForm (const)",
479+
"anonymousFormIntakeCandidates (function)",
480+
"anonymousFormIntakeSlug (function)",
481+
"anonymousFormIntakeSlugs (function)",
478482
"appForm (const)",
479483
"chartAggregateCategoryKey (function)",
480484
"chartAggregateResultKeys (function)",
@@ -518,6 +522,7 @@
518522
"pageComponentSlotPositions (function)",
519523
"pageForm (const)",
520524
"partitionAssembledViewArtifacts (function)",
525+
"publicFormSlug (function)",
521526
"reactBlockTagFor (function)",
522527
"reportForm (const)",
523528
"reportSelectionOrder (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"AddRecordConfig": "src/ui/view.zod.ts#AddRecordConfig (type)",
4242
"AddRecordConfigParsed": "src/ui/view.zod.ts#AddRecordConfigParsed (type)",
4343
"AddRecordConfigSchema": "src/ui/view.zod.ts#AddRecordConfigSchema (const)",
44+
"AnonymousFormIntakeCandidate": "src/ui/anonymous-form-intake.ts#AnonymousFormIntakeCandidate (interface)",
4445
"App": "src/ui/app.zod.ts#App (type)",
4546
"AppBranding": "src/ui/app.zod.ts#AppBranding (type)",
4647
"AppBrandingSchema": "src/ui/app.zod.ts#AppBrandingSchema (const)",
@@ -460,6 +461,9 @@
460461
"WidgetColorVariant": "src/ui/dashboard.zod.ts#WidgetColorVariant (type)",
461462
"WidgetColorVariantSchema": "src/ui/dashboard.zod.ts#WidgetColorVariantSchema (const)",
462463
"actionForm": "src/ui/action.form.ts#actionForm (const)",
464+
"anonymousFormIntakeCandidates": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (function)",
465+
"anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)",
466+
"anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)",
463467
"appForm": "src/ui/app.form.ts#appForm (const)",
464468
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
465469
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
@@ -503,6 +507,7 @@
503507
"pageComponentSlotPositions": "src/ui/component.zod.ts#pageComponentSlotPositions (function)",
504508
"pageForm": "src/ui/page.form.ts#pageForm (const)",
505509
"partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)",
510+
"publicFormSlug": "src/ui/anonymous-form-intake.ts#publicFormSlug (function)",
506511
"reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)",
507512
"reportForm": "src/ui/report.form.ts#reportForm (const)",
508513
"reportSelectionOrder": "src/ui/report.zod.ts#reportSelectionOrder (function)",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SharingConfigSchema } from './sharing.zod';
5+
import {
6+
anonymousFormIntakeCandidates,
7+
anonymousFormIntakeSlug,
8+
anonymousFormIntakeSlugs,
9+
publicFormSlug,
10+
} from './anonymous-form-intake';
11+
import * as uiEntry from './index';
12+
13+
// The candidates half of the anonymous-intake rule, as the spec declares it.
14+
// `@objectstack/metadata-core` re-exports these exact bindings to the server's
15+
// doors (pinned there by identity), so what this file pins is what the doors
16+
// serve and what a console importing `@objectstack/spec/ui` reads.
17+
18+
const OPEN = { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' };
19+
20+
describe('the /ui entry exports the candidates half', () => {
21+
it.each([
22+
'publicFormSlug',
23+
'anonymousFormIntakeSlug',
24+
'anonymousFormIntakeCandidates',
25+
'anonymousFormIntakeSlugs',
26+
] as const)('%s is the module function', (name) => {
27+
expect(typeof uiEntry[name]).toBe('function');
28+
});
29+
30+
it('the same bindings, not copies', () => {
31+
expect(uiEntry.publicFormSlug).toBe(publicFormSlug);
32+
expect(uiEntry.anonymousFormIntakeSlug).toBe(anonymousFormIntakeSlug);
33+
expect(uiEntry.anonymousFormIntakeCandidates).toBe(anonymousFormIntakeCandidates);
34+
expect(uiEntry.anonymousFormIntakeSlugs).toBe(anonymousFormIntakeSlugs);
35+
});
36+
});
37+
38+
describe('anonymousFormIntakeSlug: which sharing opens a form to anonymous intake', () => {
39+
it('both switches on and a publicLink: open, slug normalised', () => {
40+
expect(anonymousFormIntakeSlug(OPEN)).toBe('contact-us');
41+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'forms/contact-us' })).toBe('contact-us');
42+
expect(anonymousFormIntakeSlug({ ...OPEN, publicLink: 'contact-us' })).toBe('contact-us');
43+
});
44+
45+
it.each<[string, Record<string, unknown>]>([
46+
['enabled: false', { ...OPEN, enabled: false }],
47+
['enabled absent', { allowAnonymous: true, publicLink: '/forms/contact-us' }],
48+
['allowAnonymous: false', { ...OPEN, allowAnonymous: false }],
49+
['allowAnonymous absent', { enabled: true, publicLink: '/forms/contact-us' }],
50+
['publicLink absent', { enabled: true, allowAnonymous: true }],
51+
['publicLink empty', { ...OPEN, publicLink: '' }],
52+
['a truthy non-boolean switch', { ...OPEN, enabled: 'true' }],
53+
])('%s: closed', (_label, sharing) => {
54+
expect(anonymousFormIntakeSlug(sharing)).toBeNull();
55+
});
56+
57+
it('a raw body and its parse get the same answer (the schema defaults `enabled` to false)', () => {
58+
for (const raw of [OPEN, { allowAnonymous: true, publicLink: '/forms/contact-us' }, { ...OPEN, enabled: false }]) {
59+
expect(anonymousFormIntakeSlug(SharingConfigSchema.parse(raw))).toBe(anonymousFormIntakeSlug(raw));
60+
}
61+
});
62+
63+
it('not an object: closed', () => {
64+
expect(anonymousFormIntakeSlug(undefined)).toBeNull();
65+
expect(anonymousFormIntakeSlug(null)).toBeNull();
66+
expect(anonymousFormIntakeSlug('x')).toBeNull();
67+
});
68+
69+
it('publicFormSlug: `/forms/x`, `forms/x`, `x` and extra leading slashes are one slug', () => {
70+
expect(['/forms/x', 'forms/x', 'x', '//forms/x'].map(publicFormSlug)).toEqual(['x', 'x', 'x', 'x']);
71+
});
72+
});
73+
74+
describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs: each of the three shapes alone', () => {
75+
// One row per shape a view carries a form in; each is judged open and then
76+
// withdrawn through either switch.
77+
const SHAPES: Array<[string, (sharing: Record<string, unknown>) => Record<string, unknown>, string | undefined]> = [
78+
['the nested form', (sharing) => ({ name: 'inquiry.default', form: { sharing } }), undefined],
79+
['a formViews entry', (sharing) => ({ name: 'inquiry', formViews: { contact: { sharing } } }), 'contact'],
80+
[
81+
"a viewKind: 'form' item's config",
82+
(sharing) => ({ name: 'inquiry.contact', object: 'inquiry', viewKind: 'form', config: { sharing } }),
83+
'inquiry.contact',
84+
],
85+
];
86+
87+
it.each(SHAPES)('%s: open', (_label, build, key) => {
88+
const view = build(OPEN);
89+
const c = anonymousFormIntakeCandidates(view);
90+
expect(c).toHaveLength(1);
91+
expect(c[0].key).toBe(key);
92+
expect('key' in c[0]).toBe(key !== undefined);
93+
expect(c[0].slug).toBe('contact-us');
94+
expect(c[0].form.sharing).toBe(OPEN);
95+
expect(anonymousFormIntakeSlugs(view)).toEqual(['contact-us']);
96+
});
97+
98+
it.each(SHAPES)('%s: withdrawn through either switch, or with no link', (_label, build) => {
99+
for (const sharing of [{ ...OPEN, enabled: false }, { ...OPEN, allowAnonymous: false }, { enabled: true, allowAnonymous: true }]) {
100+
expect(anonymousFormIntakeCandidates(build(sharing))).toEqual([]);
101+
expect(anonymousFormIntakeSlugs(build(sharing))).toEqual([]);
102+
}
103+
});
104+
105+
it("a config without viewKind: 'form' is not a form", () => {
106+
expect(anonymousFormIntakeCandidates({ name: 'inquiry.grid', viewKind: 'list', config: { sharing: OPEN } })).toEqual([]);
107+
expect(anonymousFormIntakeCandidates({ name: 'inquiry.grid', config: { sharing: OPEN } })).toEqual([]);
108+
});
109+
});
110+
111+
describe('anonymousFormIntakeCandidates / anonymousFormIntakeSlugs: all three shapes in one body', () => {
112+
const view = (sharing: Record<string, unknown>) => ({
113+
name: 'inquiry.contact',
114+
object: 'inquiry',
115+
form: { data: { object: 'inquiry' }, sharing: { ...sharing, publicLink: '/forms/nested' } },
116+
formViews: {
117+
a: { sharing: { ...sharing, publicLink: '/forms/a' } },
118+
b: { sharing: { ...OPEN, enabled: false, publicLink: '/forms/b' } },
119+
},
120+
viewKind: 'form',
121+
config: { sharing: { ...sharing, publicLink: 'forms/flat' } },
122+
});
123+
124+
it('scans the nested form, every formViews entry and the flattened config, in that order, open ones only', () => {
125+
const c = anonymousFormIntakeCandidates(view(OPEN));
126+
expect(c.map((x) => [x.key, x.slug])).toEqual([
127+
[undefined, 'nested'],
128+
['a', 'a'],
129+
['inquiry.contact', 'flat'],
130+
]);
131+
expect(anonymousFormIntakeSlugs(view(OPEN))).toEqual(['a', 'flat', 'nested']);
132+
});
133+
134+
it('withdrawn through either switch: no candidate on any shape', () => {
135+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, enabled: false }))).toEqual([]);
136+
expect(anonymousFormIntakeSlugs(view({ ...OPEN, allowAnonymous: false }))).toEqual([]);
137+
});
138+
139+
it('de-duplicates and sorts slugs; tolerates non-object input', () => {
140+
expect(anonymousFormIntakeSlugs({ formViews: { x: { sharing: OPEN }, y: { sharing: { ...OPEN, publicLink: 'contact-us' } } } }))
141+
.toEqual(['contact-us']);
142+
expect(anonymousFormIntakeSlugs(null)).toEqual([]);
143+
expect(anonymousFormIntakeSlugs('view')).toEqual([]);
144+
expect(anonymousFormIntakeSlugs({ formViews: { x: null } })).toEqual([]);
145+
});
146+
});

0 commit comments

Comments
 (0)