form0-core 0.1.25 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,82 +1,145 @@
1
1
  # form0-core
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/form0-core)](https://www.npmjs.com/package/form0-core)
4
- [![NPM Downloads](https://img.shields.io/npm/dt/form0-core)](https://www.npmjs.com/package/form0-core)
4
+ [![NPM Downloads](https://img.shields.io/npm/dm/form0-core)](https://www.npmjs.com/package/form0-core)
5
+ [![CI](https://github.com/paqu-io/form0-core/actions/workflows/ci.yml/badge.svg)](https://github.com/paqu-io/form0-core/actions/workflows/ci.yml)
5
6
  ![NPM License](https://img.shields.io/npm/l/form0-core)
6
7
  [![Docs](https://img.shields.io/badge/docs-docs.form0.dev-2563eb)](https://docs.form0.dev)
7
8
  [![Website](https://img.shields.io/badge/site-form0.dev-0f172a)](https://form0.dev)
8
9
  ![NPM Last Update](https://img.shields.io/npm/last-update/form0-core)
10
+ [![Socket](https://socket.dev/api/badge/npm/package/form0-core)](https://socket.dev/npm/package/form0-core)
9
11
 
10
- > [!WARNING]
11
- > form0 is in active, very early development. Do not use in production. Expect breaking
12
- > changes and unstable behavior.
12
+ > [!NOTE]
13
+ > form0 is in active development and is available to use today. Its schema format and core
14
+ > concepts are stable in practice, but releases before 1.0 may include breaking changes. Pin your
15
+ > versions and review the release notes when upgrading. A formally stable release is coming.
13
16
 
14
- form0-core is the schema-driven engine that powers [form0 open-source ecosystem](https://form0.dev). It is framework-agnostic and runs in any JavaScript runtime (Node.js, browsers, React Native, etc.)
17
+ `form0-core` is the framework-agnostic, schema-driven engine behind the
18
+ [form0 open-source ecosystem](https://form0.dev). It evaluates calculations, conditional
19
+ visibility, requirements, read-only rules, validation, and form events in Node.js, browsers, and
20
+ React Native.
15
21
 
16
- ## 🚀 Start with the CLI (recommended)
22
+ ## 🚀 Start with the CLI
17
23
 
18
- The entry point for most users is [form0-cli](https://github.com/paqu-io/form0-cli). Follow the [quickstart](https://docs.form0.dev/getting-started/quickstart) to create a project and preview
19
- your schema.
24
+ Most users should begin with [`form0-cli`](https://github.com/paqu-io/form0-cli) rather than install
25
+ the engine directly. Follow the [quickstart](https://docs.form0.dev/getting-started/quickstart) to
26
+ create a project, edit a schema, and preview a form.
20
27
 
21
- ## 🗂️ Documentation
28
+ Use `form0-core` directly when you are building a renderer, integration, developer tool, or other
29
+ custom form runtime.
22
30
 
23
- - Overview: https://docs.form0.dev/core/overview
24
- - Concepts: https://docs.form0.dev/core/concepts
25
- - Full docs: https://docs.form0.dev
26
-
27
- ## Direct usage (advanced)
28
-
29
- If you are integrating the engine directly:
31
+ ## 📦 Installation
30
32
 
31
33
  ```bash
32
34
  npm install form0-core
33
35
  ```
34
36
 
35
- `form0-core` owns behavioral schema concerns such as fields, conditions, calculations,
36
- events, and AI metadata. Applications may still attach optional top-level metadata such as
37
- `id` (unique form identifier), `status` (publication state), `version` (schema version),
38
- scope fields like `main_org_id`, and media or location settings. Operational counters like
39
- `record_count` and `record_last_change_at` should stay platform-owned and be injected at
40
- application or API boundaries, not treated as engine-authored schema. A top-level
41
- `form.version` may still exist, but the engine does not bump or consume it.
37
+ ## ⚡ Quick example
38
+
39
+ ```javascript
40
+ import { createFormEngine } from 'form0-core';
41
+
42
+ const schema = {
43
+ form: {
44
+ name: 'Contact form',
45
+ status_field: null,
46
+ elements: [
47
+ {
48
+ type: 'TextField',
49
+ key: 'name',
50
+ data_name: 'name',
51
+ label: 'Name',
52
+ display: 'default',
53
+ description: null,
54
+ description_mode: null,
55
+ required: true,
56
+ required_conditions: null,
57
+ visible: true,
58
+ visible_conditions: null,
59
+ read_only: false,
60
+ read_only_conditions: null,
61
+ default_value: null,
62
+ pattern: null,
63
+ pattern_description: null,
64
+ supporting_image: false,
65
+ supporting_image_path: null,
66
+ supporting_image_display: null,
67
+ },
68
+ ],
69
+ },
70
+ };
71
+
72
+ const engine = createFormEngine({
73
+ schema,
74
+ initialValues: { name: 'Ada' },
75
+ });
76
+
77
+ engine.eval();
78
+ console.log(engine.getState());
79
+ ```
80
+
81
+ The engine API exposes `eval()` for calculations, conditions, and validation; `trigger()` for form
82
+ events; and `getState()` for current values and evaluated field state.
83
+
84
+ ## 🔒 Security
85
+
86
+ > [!CAUTION]
87
+ > Schema expressions and event scripts execute JavaScript. Only evaluate schemas from authors you
88
+ > trust. `SAFE` and `CUSTOM` modes provide validation controls; they are not security sandboxes.
89
+
90
+ Read the [security policy](./SECURITY.md) before evaluating schemas outside a fully trusted
91
+ authoring workflow.
92
+
93
+ ## Schema and record ownership
94
+
95
+ `form0-core` owns behavioral schema concerns such as fields, conditions, calculations, events, and
96
+ AI metadata. Applications may attach optional top-level metadata such as form identifiers,
97
+ publication state, schema version, organization scope, and media or location settings.
98
+ Operational counters should remain platform-owned and be injected at application or API
99
+ boundaries.
42
100
 
43
101
  ### Record-side contract
44
102
 
45
- `form0-core` intentionally uses two different choice-value shapes:
103
+ `form0-core` intentionally uses two choice-value shapes:
46
104
 
47
- - Live engine / renderer values use renderer shape:
48
- - single / boolean: `{ choice, other }`
49
- - multi: `{ choices, other }`
50
- - Structured records use canonical stored shape:
51
- - single / boolean: `{ choice_value, other_value }`
52
- - multi: `{ choices_value, other_value }`
105
+ - Live engine and renderer values:
106
+ - single choice and boolean: `{ choice, other }`
107
+ - multiple choice: `{ choices, other }`
108
+ - Canonical structured records:
109
+ - single choice and boolean: `{ choice_value, other_value }`
110
+ - multiple choice: `{ choices_value, other_value }`
53
111
 
54
- Record-side utilities follow this contract:
112
+ Record utilities follow this contract:
55
113
 
56
- - `createStructuredRecord()` outputs canonical stored records
57
- - `normalizeStructuredRecord()` consumes and returns canonical stored records
58
- - `buildFormRecordSnapshot()` consumes canonical stored records and returns renderer snapshot values
59
- - `projectDatasetRowValues()` consumes canonical stored rows
60
- - Choice `FIELD_SPECS` keep separate validators by context:
61
- - `valueValidator` validates live engine / renderer values
62
- - `recordValueValidator` validates canonical stored record values
114
+ - `createStructuredRecord()` produces canonical stored records.
115
+ - `normalizeStructuredRecord()` consumes and returns canonical stored records.
116
+ - `buildFormRecordSnapshot()` converts canonical records into renderer snapshot values.
117
+ - `projectDatasetRowValues()` consumes canonical stored rows.
63
118
 
64
119
  Record status remains top-level as `@status`; it is not stored inside `form_values`.
65
120
 
66
- ## Security
121
+ ## ✅ Requirements
122
+
123
+ - Node.js 22 or newer
124
+ - An ESM-capable runtime or bundler
125
+
126
+ ## 📚 Documentation
67
127
 
68
- See `SECURITY.md` for security modes and configuration.
128
+ - [Core overview](https://docs.form0.dev/core/overview)
129
+ - [Core concepts](https://docs.form0.dev/core/concepts)
130
+ - [Full documentation](https://docs.form0.dev)
69
131
 
70
- ## Requirements
132
+ ## 🔗 Related repositories
71
133
 
72
- - Node.js 18+
134
+ - [form0-cli](https://github.com/paqu-io/form0-cli) — recommended project entry point
135
+ - [form0-react](https://github.com/paqu-io/form0-react) — React bindings and renderers
136
+ - [form0-react-native](https://github.com/paqu-io/form0-react-native) — React Native bindings and renderers
73
137
 
74
- ## Related repositories
138
+ ## 🤝 Support and contributing
75
139
 
76
- - [form0-cli](https://github.com/paqu-io/form0-cli) - Command-line interface
77
- - [form0-react](https://github.com/paqu-io/form0-react) - React components
78
- - [form0-react-native](https://github.com/paqu-io/form0-react-native) - React Native components
140
+ See [SUPPORT.md](https://github.com/paqu-io/form0-core/blob/main/SUPPORT.md) for help and
141
+ [CONTRIBUTING.md](https://github.com/paqu-io/form0-core/blob/main/CONTRIBUTING.md) to contribute.
79
142
 
80
- ## Contributing
143
+ ## 📄 License
81
144
 
82
- Contributions are welcome! Please feel free to submit [issues](https://github.com/paqu-io/form0-core/issues) and [pull requests](https://github.com/paqu-io/form0-core/pulls).
145
+ [MIT](./LICENSE)
package/SECURITY.md CHANGED
@@ -1,189 +1,53 @@
1
- # Security Features
1
+ # Security Policy
2
2
 
3
- > [!CAUTION] > `SAFE` and `CUSTOM` mode are validation aids, not security sandboxes. The
4
- > evaluator compiles customer expressions with `new Function`; blocked-pattern
5
- > checks can be bypassed through dynamic property access, and
6
- > `maxExecutionTime` is not currently enforced. Do not execute untrusted
7
- > schemas in a privileged server or browser context. See
8
- > `analyses/SAFE_MODE_SECURITY_ANALYSIS.md` for the accepted critical finding
9
- > and `analyses/UNTRUSTED_JAVASCRIPT_EXECUTION_ARCHITECTURE.md` for the selected
10
- > isolation architecture that preserves vanilla JavaScript and imperative event scripts.
3
+ Security reports are taken seriously. Please report vulnerabilities privately so they can be
4
+ investigated and fixed before public disclosure.
11
5
 
12
- form0-core provides configurable security options for expression evaluation to balance flexibility and safety.
6
+ ## Reporting a vulnerability
13
7
 
14
- ## Security Modes
8
+ Use the
9
+ [form0-core private vulnerability report](https://github.com/paqu-io/form0-core/security/advisories/new).
10
+ Do not open a public issue for a suspected vulnerability.
15
11
 
16
- ### TRUSTED (Default)
12
+ Include:
17
13
 
18
- - **Full JavaScript access** - Current behavior maintained
19
- - **No restrictions** - All expressions execute as-is
20
- - **Use case**: Internal tools, trusted developers, client-side only
14
+ - the affected version and runtime;
15
+ - a minimal reproduction or proof of concept;
16
+ - the impact you believe is possible;
17
+ - any mitigations you have already identified; and
18
+ - whether the issue has been disclosed anywhere else.
21
19
 
22
- ```javascript
23
- import { createFormEngine } from 'form0-core';
20
+ Reports affecting any version are welcome. When possible, reproduce the issue with the latest
21
+ release. Security fixes are normally released for the latest version; older versions are assessed
22
+ case by case.
24
23
 
25
- // Default behavior - no security parameter needed
26
- const engine = createFormEngine({ schema });
24
+ Maintainers will review the report, may ask for more information, and will coordinate disclosure
25
+ after a fix or mitigation is available. Please keep the report private during that process.
27
26
 
28
- // Or explicitly set trusted mode
29
- const engine = createFormEngine({
30
- schema,
31
- security: { mode: 'trusted' },
32
- });
33
- ```
27
+ ## Trust model
34
28
 
35
- ### SAFE (validation only; not a trust boundary)
29
+ form0-core supports JavaScript expressions and imperative event scripts. These expressions and
30
+ scripts are executable code, so schemas that contain them must come from authors you trust.
36
31
 
37
- - **Restricted context** - Only whitelisted globals available
38
- - **Pattern blocking** - Dangerous patterns are blocked
39
- - **Use case**: Diagnostics for trusted authors. It is not suitable for
40
- adversarial or otherwise untrusted expressions.
32
+ **SAFE** and **CUSTOM** modes provide validation and configuration controls. They are not security
33
+ sandboxes, and pattern-based restrictions must not be used as a boundary for executing adversarial
34
+ schemas in a privileged browser, server, CLI, or mobile context.
41
35
 
42
- ```javascript
43
- import { createFormEngine, SECURITY_MODES, SAFE_SECURITY_CONFIG } from 'form0-core';
36
+ Giving an untrusted user control over executable schema expressions and then observing that the
37
+ expressions can execute JavaScript is outside this project's security boundary. Reports remain in
38
+ scope when behavior crosses a documented boundary—for example, when data that is not intended to
39
+ be executable becomes code, or when an attacker can affect a schema or runtime context they were
40
+ not authorized to control.
44
41
 
45
- // Simple safe mode
46
- const engine = createFormEngine({
47
- schema,
48
- security: { mode: SECURITY_MODES.SAFE },
49
- });
42
+ Applications are responsible for authenticating schema authors, authorizing schema changes, and
43
+ isolating execution when schemas cannot be trusted.
50
44
 
51
- // Or use predefined safe config
52
- const engine = createFormEngine({
53
- schema,
54
- security: SAFE_SECURITY_CONFIG,
55
- });
56
- ```
45
+ ## Maintainer security documents
57
46
 
58
- ### CUSTOM
47
+ The public documents in
48
+ [analyses](https://github.com/paqu-io/form0-core/tree/main/analyses) record the current threat model,
49
+ known limitations, and the planned isolation architecture. They are maintainer-oriented
50
+ engineering documents, not claims that the current validation modes provide isolation.
59
51
 
60
- - **User-defined rules** - Configure your own security settings
61
- - **Flexible restrictions** - Mix and match security features
62
- - **Use case**: Specific security requirements
63
-
64
- ```javascript
65
- const engine = createFormEngine({
66
- schema,
67
- security: {
68
- mode: SECURITY_MODES.CUSTOM,
69
- maxExecutionTime: 1000,
70
- allowedGlobals: ['Math', 'Date', 'Number'],
71
- blockedPatterns: [/\bwindow\b/, /\bdocument\b/, /\bfetch\b/],
72
- },
73
- });
74
- ```
75
-
76
- ## Security Configuration Options
77
-
78
- ```javascript
79
- // TRUSTED mode (default) - no additional config needed
80
- const trustedConfig = {
81
- mode: 'trusted', // Full JavaScript access
82
- };
83
-
84
- // SAFE mode - uses predefined safe settings
85
- const safeConfig = {
86
- mode: 'safe', // Automatically applies safe defaults
87
- };
88
-
89
- // CUSTOM mode - define your own rules
90
- const customConfig = {
91
- mode: 'custom',
92
- maxExecutionTime: 1000, // Milliseconds (future feature)
93
- maxCallStackDepth: 100, // Maximum recursion depth (future feature)
94
- allowedGlobals: ['Math', 'Date', 'JSON'], // Whitelisted global objects
95
- blockedPatterns: [/\beval\b/, /\bwindow\b/], // Regex patterns to block
96
- };
97
- ```
98
-
99
- ## Default Blocked Patterns (Safe Mode)
100
-
101
- The following patterns are blocked by default in safe mode:
102
-
103
- - `eval`, `Function` - Code execution
104
- - `window`, `document` - Browser globals
105
- - `process`, `require` - Node.js globals
106
- - `fetch`, `XMLHttpRequest` - Network requests
107
- - `localStorage`, `sessionStorage` - Storage APIs
108
- - `__proto__`, `constructor`, `prototype` - Prototype pollution
109
-
110
- ## Migration Guide
111
-
112
- ### No Changes Needed
113
-
114
- Existing code continues to work unchanged:
115
-
116
- ```javascript
117
- // This still works exactly as before
118
- const engine = createFormEngine({ schema, initialValues });
119
- ```
120
-
121
- ### Adding Security
122
-
123
- To add security, simply include the security parameter:
124
-
125
- ```javascript
126
- // Add validation-oriented safe mode. This is not a sandbox.
127
- const engine = createFormEngine({
128
- schema,
129
- initialValues,
130
- security: { mode: 'safe' },
131
- });
132
- ```
133
-
134
- ## Examples
135
-
136
- ### Safe Mathematical Calculations
137
-
138
- ```javascript
139
- const schema = {
140
- form: {
141
- elements: [
142
- {
143
- type: 'CalculatedField',
144
- data_name: 'result',
145
- calculate: 'Math.max($value1, $value2) * 1.1', // ✅ Works in safe mode
146
- },
147
- ],
148
- },
149
- };
150
- ```
151
-
152
- ### Blocked Dangerous Expressions
153
-
154
- ```javascript
155
- const schema = {
156
- form: {
157
- elements: [
158
- {
159
- type: 'CalculatedField',
160
- data_name: 'result',
161
- calculate: 'window.alert("hello")', // ❌ Blocked in safe mode
162
- },
163
- ],
164
- },
165
- };
166
- ```
167
-
168
- ### Custom Security Rules
169
-
170
- ```javascript
171
- const engine = createFormEngine({
172
- schema,
173
- security: {
174
- mode: 'custom',
175
- allowedGlobals: ['Math'], // Only Math allowed
176
- blockedPatterns: [/\bDate\b/], // Block Date usage
177
- },
178
- });
179
- ```
180
-
181
- ## Testing Security
182
-
183
- Use the included test file to verify security behavior:
184
-
185
- ```bash
186
- node test/security-test.js
187
- ```
188
-
189
- This will show how different expressions behave under different security modes.
52
+ For ordinary usage questions, see
53
+ [SUPPORT.md](https://github.com/paqu-io/form0-core/blob/main/SUPPORT.md).
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.25",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "description": "Schema-driven engine that powers form0 ecosystem. Looking for the full form0 ecosystem? Install the CLI: `npm install -g form0-cli`",
7
+ "description": "The schema-driven form engine behind form0, the open-source form ecosystem by paqu.io. Framework-agnostic: conditional visibility, validation and calculations from a JSON schema, in Node.js, browsers and React Native.",
8
8
  "main": "src/index.js",
9
9
  "type": "module",
10
+ "files": [
11
+ "src",
12
+ "SECURITY.md"
13
+ ],
10
14
  "scripts": {
11
- "test": "node tests/form0-core.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
12
- "format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md}\"",
13
- "format:check": "prettier --check \"**/*.{js,jsx,json,ts,tsx,md}\""
15
+ "test": "node tests/form0-core.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
16
+ "format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
17
+ "format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
18
+ "lint:check": "eslint src tests",
19
+ "package:check": "node scripts/check-package-contents.mjs",
20
+ "security:audit:prod": "npm audit --omit=dev --omit=peer --audit-level=high",
21
+ "check": "npm run format:check && npm run lint:check && npm test",
22
+ "release:check": "npm run check && npm run security:audit:prod && npm run package:check"
14
23
  },
15
24
  "exports": {
16
25
  ".": {
@@ -22,15 +31,17 @@
22
31
  "url": "git+https://github.com/paqu-io/form0-core.git"
23
32
  },
24
33
  "keywords": [
25
- "form",
26
- "form-builder",
27
- "form-generator",
34
+ "form0",
35
+ "paqu-io",
28
36
  "form-engine",
29
- "schema",
37
+ "schema-driven-forms",
30
38
  "json-schema",
31
- "react",
32
- "react-native",
33
- "dynamic-forms"
39
+ "dynamic-forms",
40
+ "conditional-logic",
41
+ "form-validation",
42
+ "calculated-fields",
43
+ "headless-forms",
44
+ "framework-agnostic"
34
45
  ],
35
46
  "author": "paqu.io <hello@paqu.io> (https://paqu.io/)",
36
47
  "contributors": [
@@ -44,9 +55,12 @@
44
55
  },
45
56
  "homepage": "https://form0.dev",
46
57
  "engines": {
47
- "node": ">=18.0.0"
58
+ "node": ">=22.0.0"
48
59
  },
49
60
  "devDependencies": {
61
+ "@eslint/js": "^10.0.1",
62
+ "eslint": "^10.5.0",
63
+ "globals": "^17.12.0",
50
64
  "ignore": "^7.0.5",
51
65
  "prettier": "^3.5.3"
52
66
  }
@@ -118,10 +118,7 @@ export const calculationBuiltins = buildBuiltinObject([
118
118
  ...CALCULATION_ONLY_ENTRIES,
119
119
  ]);
120
120
 
121
- export const eventBuiltins = buildBuiltinObject([
122
- ...COMMON_RUNTIME_ENTRIES,
123
- ...EVENT_ONLY_ENTRIES,
124
- ]);
121
+ export const eventBuiltins = buildBuiltinObject([...COMMON_RUNTIME_ENTRIES, ...EVENT_ONLY_ENTRIES]);
125
122
 
126
123
  // Backward-compatible alias for calculation runtime helpers.
127
124
  export const builtins = calculationBuiltins;
package/src/index.js CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  getFieldExportKind,
40
40
  getFieldQuerySemantics,
41
41
  projectDatasetRowValues,
42
+ resolveDatasetRowTitle,
42
43
  resolveDatasetDescriptorById,
43
44
  } from './utilities/dataset-descriptors.js';
44
45
  export { generateUuidV7 } from './utilities/uuid.js';
@@ -211,9 +211,7 @@ function validateFillStrategy(strategy, errors, prefix) {
211
211
  }
212
212
 
213
213
  if (!FILL_STRATEGIES.has(strategy)) {
214
- errors.push(
215
- `${prefix}fillStrategy must be one of: ${Array.from(FILL_STRATEGIES).join(', ')}`
216
- );
214
+ errors.push(`${prefix}fillStrategy must be one of: ${Array.from(FILL_STRATEGIES).join(', ')}`);
217
215
  }
218
216
  }
219
217
 
@@ -19,7 +19,8 @@ function appendSuffix(base, suffix, maxLength = null) {
19
19
  }
20
20
 
21
21
  function createScopedKey(baseKey, suffix) {
22
- const cleanBase = typeof baseKey === 'string' ? baseKey.replace(/^~+/, '').replace(/_+$/, '') : '';
22
+ const cleanBase =
23
+ typeof baseKey === 'string' ? baseKey.replace(/^~+/, '').replace(/_+$/, '') : '';
23
24
  const scopedBase = appendSuffix(cleanBase, suffix);
24
25
  return `~${scopedBase}`;
25
26
  }
@@ -932,6 +932,7 @@ export const FIELD_SPECS = {
932
932
  visible_conditions: { type: 'object', required: true, nullable: true },
933
933
  location_enabled: { type: 'boolean', required: true, nullable: false },
934
934
  location_required: { type: 'boolean', required: true, nullable: false },
935
+ title_field: { type: 'object', required: false, nullable: true },
935
936
  elements: { type: 'array', required: true, nullable: false },
936
937
  },
937
938
  schemaValidators: [
@@ -3,6 +3,73 @@ import { validateFieldSchema, validateDefaultValue } from './field-schema-regist
3
3
  import { isSupportedFieldType } from '../utilities/field-types.js';
4
4
  import { validateFieldConditions } from './operators.js';
5
5
  import { validateFormAIMetadata } from './ai-metadata-validator.js';
6
+ import { buildDatasetDescriptors } from '../utilities/dataset-descriptors.js';
7
+
8
+ const toNonEmptyString = (value) =>
9
+ typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
10
+
11
+ const getDescriptorFieldReferences = (field) =>
12
+ new Set(
13
+ [field?.field_id, field?.output_key, field?.key, field?.data_name, ...(field?.aliases || [])]
14
+ .map(toNonEmptyString)
15
+ .filter(Boolean)
16
+ );
17
+
18
+ const validateScopedTitleFields = (form, errors) => {
19
+ const descriptors = buildDatasetDescriptors({ form });
20
+ const allReferenceDatasets = new Map();
21
+
22
+ descriptors.forEach((descriptor) => {
23
+ (descriptor.fields || []).forEach((field) => {
24
+ getDescriptorFieldReferences(field).forEach((reference) => {
25
+ if (!allReferenceDatasets.has(reference)) {
26
+ allReferenceDatasets.set(reference, new Set());
27
+ }
28
+ allReferenceDatasets.get(reference).add(descriptor.id);
29
+ });
30
+ });
31
+ });
32
+
33
+ descriptors.forEach((descriptor) => {
34
+ const titleField = descriptor.title_field;
35
+ if (!titleField) {
36
+ return;
37
+ }
38
+
39
+ const scopeLabel = descriptor.kind === 'root' ? 'title_field' : `RepeatableSection title_field`;
40
+ const titleValidation = validateFieldSchema(titleField);
41
+ if (!titleValidation.isValid) {
42
+ errors.push(...titleValidation.errors.map((error) => `${scopeLabel}: ${error}`));
43
+ return;
44
+ }
45
+
46
+ const scopedFields = descriptor.fields || [];
47
+ titleField.elements.forEach((entry) => {
48
+ const reference = toNonEmptyString(entry);
49
+ if (!reference) {
50
+ return;
51
+ }
52
+
53
+ const matchedField = scopedFields.find((field) =>
54
+ getDescriptorFieldReferences(field).has(reference)
55
+ );
56
+ if (!matchedField) {
57
+ if (allReferenceDatasets.has(reference)) {
58
+ errors.push(`${scopeLabel} reference "${reference}" is outside its record scope`);
59
+ } else {
60
+ errors.push(`${scopeLabel} reference "${reference}" does not match any field`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (matchedField.title_eligible !== true) {
66
+ errors.push(
67
+ `${scopeLabel} reference "${reference}" uses unsupported field type ${matchedField.field_type}`
68
+ );
69
+ }
70
+ });
71
+ });
72
+ };
6
73
 
7
74
  export function validateSchema(form) {
8
75
  const fields = flattenFields(form.elements);
@@ -180,12 +247,7 @@ export function validateSchema(form) {
180
247
  }
181
248
  }
182
249
  }
183
- if (form.title_field) {
184
- const titleValidation = validateFieldSchema(form.title_field);
185
- if (!titleValidation.isValid) {
186
- errors.push(...titleValidation.errors.map((e) => `title_field: ${e}`));
187
- }
188
- }
250
+ validateScopedTitleFields(form, errors);
189
251
 
190
252
  // Add duplicate errors to the main errors array
191
253
  if (duplicateDataNames.size > 0) {
@@ -225,7 +287,11 @@ export function validateSchema(form) {
225
287
  }
226
288
  }
227
289
 
228
- if (!entry.form_id || typeof entry.form_id !== 'string' || entry.form_id.trim() === '') {
290
+ if (
291
+ !entry.form_id ||
292
+ typeof entry.form_id !== 'string' ||
293
+ entry.form_id.trim() === ''
294
+ ) {
229
295
  errors.push(`form_links.to[${index}].form_id must be a non-empty string`);
230
296
  }
231
297
  });
@@ -242,7 +308,11 @@ export function validateSchema(form) {
242
308
  return;
243
309
  }
244
310
 
245
- if (!entry.form_id || typeof entry.form_id !== 'string' || entry.form_id.trim() === '') {
311
+ if (
312
+ !entry.form_id ||
313
+ typeof entry.form_id !== 'string' ||
314
+ entry.form_id.trim() === ''
315
+ ) {
246
316
  errors.push(`form_links.from[${index}].form_id must be a non-empty string`);
247
317
  }
248
318