@owlmeans/client-planning 0.1.18-rc.29 → 0.1.18-rc.30

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
@@ -25,6 +25,38 @@ bun add @owlmeans/client-planning@^0.1.18-rc.29
25
25
  - **`head > seq`** — a card with a transition allocated and not yet folded; the mirror's `applyReceipt` marks it
26
26
  the moment the server answers.
27
27
 
28
+ ## Forms under a strict Content Security Policy
29
+
30
+ Use the exact resolved card or assignee type's field schema:
31
+
32
+ ```typescript
33
+ import { makePlanningFieldValidator } from '@owlmeans/client-planning'
34
+
35
+ const form = makePlanningFieldValidator(resolvedType.fields)
36
+ const checked = form.validateFields(fields)
37
+ if (checked.valid) {
38
+ // Submit this same fields object through the stock planning facade.
39
+ }
40
+ ```
41
+
42
+ The runtime interpreter supports dynamic organization and project definitions without `eval` or
43
+ `new Function`. It preserves the schema and payload, nested objects, required fields, enum/const,
44
+ dates, references, compositions and AJV nullable semantics; string formats use native
45
+ `ajv-formats` full-mode predicates. Errors contain field `path`, `keyword`, `schemaPath` and a
46
+ bounded message without submitted values. The server's authoritative AJV validates every write.
47
+
48
+ The schema contract is Draft 7 plus `$defs` and `nullable`, with native AJV reference-sibling
49
+ semantics. References must resolve within the supplied schema. Malformed schemas, missing
50
+ references, same-instance reference cycles, other drafts/custom validation keywords, unknown or
51
+ numeric formats, format-limit extensions and `multipleOf` throw `SchemaInvalid` at preparation.
52
+ The interpreter's floating tolerance and native AJV's multiple semantics differ, so that keyword
53
+ is explicitly refused. JSON fields cannot contain dot/dollar keys or non-JSON values. Standard
54
+ annotations and `x-` metadata remain annotations. At most 64 validation errors are returned.
55
+
56
+ Surface preparation errors and keep save disabled. Do not remove constraints, choose another
57
+ schema or relax CSP. Registry `validator` / `assigneeValidator` compile with native AJV and need
58
+ code generation; browser forms under strict CSP use this API instead.
59
+
28
60
  ## Usage
29
61
 
30
62
  Wire the client into a context with the server's own tree:
@@ -131,6 +163,7 @@ that placeholder to authorize a request, and never send an organization record i
131
163
  `.applyReceipt(view)`, `.applyCards(cards)`
132
164
  - `makePlanningFeed(context, opts?)`
133
165
  - `makePlanningResourceFeed(context, opts?)`, `PlanningResourceFeedOptions`
166
+ - `makePlanningFieldValidator(schema)`, `PlanningFieldValidator`, `PlanningFieldValidation`, `PlanningFieldError`
134
167
  - `makePlanningClientLifecycle`, `PlanningClientLifecycle`; service `close()` and `scopeKey`
135
168
  - `planningContextOf(context)` (`makePlanningContextHelper`) — `.facade(scope?)`, `.model(card, scope?)`,
136
169
  `.stores()`
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/client-planning",
4
- "version": "0.1.18-rc.29",
5
- "generatedAt": "2026-10-09T20:19:04.322Z",
4
+ "version": "0.1.18-rc.30",
5
+ "generatedAt": "2026-10-10T00:34:44.458Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/client-planning
9
9
 
10
10
  **Layer:** Client
11
- **Install:** `"@owlmeans/client-planning": "^0.1.18-rc.29"` in `dependencies`
11
+ **Install:** `"@owlmeans/client-planning": "^0.1.18-rc.30"` in `dependencies`
12
12
 
13
13
  The client half of OwlMeans planning. It answers the `PlanningFacade` interface of
14
14
  `@owlmeans/planning` over the protocol tree a server mounted with `@owlmeans/server-planning`, keeps
@@ -30,6 +30,7 @@ CLI). React hooks over the mirror are `useStoreModel` / `useStoreList` from `@ow
30
30
  | `planningMirrorOf(stores).applyCommitEvent(event, facade?)` / `.applyReceipt(view)` / `.applyCards(cards)` | The folds |
31
31
  | `makePlanningFeed(context, opts?)` | Subscribe, seed, fold, refresh — `{ connected, seeded, error, ready, refresh, stop }` |
32
32
  | `planningContextOf(context).facade(scope?)` / `.model(card, scope?)` | The facade / a model with the schemas loaded |
33
+ | `makePlanningFieldValidator(schema)` | CSP-safe validation of an exact served field schema; `{ validateFields }` |
33
34
  | `CARDS`, `LINKS`, `COMMITS` | Store aliases (`planning-card-state`, `planning-link-state`, `planning-commit-state`) |
34
35
  | Types | `PlanningClientOptions`, `PlanningClientService`, `WithPlanningClient`, `PlanningSocketOpener`, `RemoteCommitSource`, `RemoteDefinitions`, `RemoteDefinitionsOptions`, `PlanningStores`, `PlanningStoreAliases`, `WithPlanningStores`, `PlanningCommitRecord`, `PlanningFeed`, `PlanningFeedOptions`, `PlanningFeedState`, `SyncOptions`, `PlanningContextHelper`, `PlanningMirror`, `SyncHelper` |
35
36
 
@@ -87,6 +88,38 @@ whole cache — an organization-wide write reaches every project's layer — and
87
88
  `model(card)` of a card resolves its type in its project's layer (a project's own id, a card's
88
89
  nearest primary project ancestor); a specification's type is always code's. A tree without `definitions` has none of this.
89
90
 
91
+ ## Browser field validation under CSP
92
+
93
+ Bind `makePlanningFieldValidator(type.fields)` to the exact definition returned by the resolved
94
+ organization or project bundle. Its `validateFields(fields)` returns `{ valid, errors }`; errors
95
+ carry decoded `path`, `keyword`, `schemaPath` and a bounded value-free `message`. Construct one JSON
96
+ fields object, validate it and send that same object. No coercion, defaults or property removal
97
+ occurs, and the schema is left unchanged. Preserve nested money objects and calendar date strings.
98
+ The native server validates every write again.
99
+
100
+ The validator interprets schemas with `@cfworker/json-schema`, with no `eval`, `new Function` or
101
+ network reference loading. Do not call `makeSchemaRegistry(...).validator()` or
102
+ `.assigneeValidator()` in a browser with strict `script-src`: those remain native AJV compilers.
103
+ Static AJV standalone compilation is suitable only when the schema cannot change after building;
104
+ organization and project definitions require the runtime interpreter.
105
+
106
+ The supported contract is Draft 7's object/array/scalar, required, enum/const, ranges, string
107
+ length/pattern, dependencies, boolean, composition and conditional keywords; `$defs`, local/nested
108
+ `$id` references and AJV's `nullable` are also supported. `$ref` sibling constraints are enforced as
109
+ by native planning AJV. String formats use the same `ajv-formats` full-mode predicates, including
110
+ real calendar dates and timezone-aware times. `nullable` adds null to the declared type while
111
+ leaving enum and const constraints intact. All references must resolve inside the supplied field
112
+ schema; productive recursive schemas are supported, same-instance reference cycles are refused.
113
+ Plain JSON values and field names without dot or dollar characters are required.
114
+
115
+ Preparation throws `SchemaInvalid` for malformed schemas, unresolved references, unsupported
116
+ drafts/keywords/formats, numeric formats, `formatMinimum`/`formatMaximum` extensions and
117
+ `multipleOf`. The latter is refused because the interpreter's epsilon and native AJV's quotient
118
+ semantics disagree for fractional and very large numbers. Standard annotation keywords and `x-`
119
+ annotations do not add validation. Never discard a refused constraint, invent a substitute schema,
120
+ enable `unsafe-eval` or proceed with a ready form after preparation fails. Show the error and retain
121
+ disabled save controls. Validation reports at most 64 errors, with paths bounded to 512 characters.
122
+
90
123
  ## Mounting in a target
91
124
 
92
125
  The web half of the target mount (`planning` → Mounting in a target).
@@ -282,3 +315,10 @@ A schema write from an old generation cannot invalidate a newer generation's cac
282
315
  - `server-planning` — the handlers this addresses, the executor, the commit hub
283
316
  - `client-job` — the same seed-then-fold shape for safe application-job views
284
317
  - `state` — `syncHelper.syncCards` is built on its criteria engine and `purge`
318
+
319
+ ## External docs
320
+
321
+ - https://ajv.js.org/security.html#content-security-policy — runtime AJV requires unsafe-eval; standalone build compilation preserves strict CSP for static schemas.
322
+ - https://www.npmjs.com/package/@cfworker/json-schema/v/4.1.1 — the interpreter supports dynamic schemas without code generation; select an explicit vocabulary and check its known limitations (4.1.1).
323
+ - https://github.com/ajv-validator/ajv-formats#full-and-fast-validation-modes — full-mode predicates validate formats completely and are reusable through the plugin's `get` API (3.0.1).
324
+ - https://ajv.js.org/json-schema.html#nullable-openapi — nullable expands the declared type without adding null to enum or const.
package/build/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type * from './events/types.js';
3
3
  export type * from './helper/types.js';
4
4
  export type * from './sync/types.js';
5
5
  export type * from './lifecycle/types.js';
6
+ export type * from './validation/types.js';
6
7
  export * from './consts.js';
7
8
  export * from './client.js';
8
9
  export * from './facade.js';
@@ -16,4 +17,5 @@ export * from './helper.js';
16
17
  export * from './resources.js';
17
18
  export * from './resource-feed.js';
18
19
  export * from './lifecycle.js';
20
+ export * from './validation.js';
19
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,mBAAmB,mBAAmB,CAAA;AACtC,mBAAmB,mBAAmB,CAAA;AACtC,mBAAmB,iBAAiB,CAAA;AACpC,mBAAmB,sBAAsB,CAAA;AAEzC,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAE3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,mBAAmB,mBAAmB,CAAA;AACtC,mBAAmB,mBAAmB,CAAA;AACtC,mBAAmB,iBAAiB,CAAA;AACpC,mBAAmB,sBAAsB,CAAA;AACzC,mBAAmB,uBAAuB,CAAA;AAE1C,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAE3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA"}
package/build/index.js CHANGED
@@ -11,4 +11,5 @@ export * from './helper.js';
11
11
  export * from './resources.js';
12
12
  export * from './resource-feed.js';
13
13
  export * from './lifecycle.js';
14
+ export * from './validation.js';
14
15
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAE3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAE3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,iBAAiB,CAAA"}
@@ -0,0 +1,16 @@
1
+ /** Draft 7 vocabulary, plus AJV's $defs spelling. Other vocabularies fail closed. */
2
+ export declare const FIELD_SCHEMA_KEYWORDS: Set<string>;
3
+ export declare const FIELD_SCHEMA_MAPS: Set<string>;
4
+ export declare const FIELD_SCHEMA_ARRAYS: Set<string>;
5
+ export declare const FIELD_SCHEMA_CHILDREN: Set<string>;
6
+ export declare const FIELD_SCHEMA_ANNOTATIONS: Set<string>;
7
+ export declare const FIELD_SCHEMA_DRAFTS: Set<string>;
8
+ /** Reserved predicates never replace cfworker's own global format definitions. */
9
+ export declare const FIELD_FORMAT_PREFIX = "owlmeans-planning:";
10
+ export declare const MAX_FIELD_ERRORS = 64;
11
+ export declare const MAX_FIELD_ERROR_PATH = 512;
12
+ export declare const REQUIRED_ERROR_PREFIX = "Instance does not have required property \"";
13
+ export declare const REQUIRED_ERROR_SUFFIX = "\".";
14
+ export declare const FIELD_ERROR_WRAPPERS: Set<string>;
15
+ export declare const FIELD_ERROR_MESSAGES: Readonly<Record<string, string>>;
16
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../../src/validation/consts.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,aAQhC,CAAA;AACF,eAAO,MAAM,iBAAiB,aAAuE,CAAA;AACrG,eAAO,MAAM,mBAAmB,aAAuC,CAAA;AACvE,eAAO,MAAM,qBAAqB,aAEhC,CAAA;AACF,eAAO,MAAM,wBAAwB,aAGnC,CAAA;AACF,eAAO,MAAM,mBAAmB,aAG9B,CAAA;AACF,kFAAkF;AAClF,eAAO,MAAM,mBAAmB,uBAAuB,CAAA;AACvD,eAAO,MAAM,gBAAgB,KAAK,CAAA;AAClC,eAAO,MAAM,oBAAoB,MAAM,CAAA;AACvC,eAAO,MAAM,qBAAqB,gDAA+C,CAAA;AACjF,eAAO,MAAM,qBAAqB,QAAO,CAAA;AACzC,eAAO,MAAM,oBAAoB,aAA+E,CAAA;AAChH,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUjE,CAAA"}
@@ -0,0 +1,42 @@
1
+ /** Draft 7 vocabulary, plus AJV's $defs spelling. Other vocabularies fail closed. */
2
+ export const FIELD_SCHEMA_KEYWORDS = new Set([
3
+ '$id', '$schema', '$ref', '$comment', '$defs', 'definitions', 'type', 'nullable', 'const', 'enum',
4
+ 'required', 'properties', 'patternProperties', 'additionalProperties', 'propertyNames',
5
+ 'minProperties', 'maxProperties', 'dependencies', 'items', 'additionalItems', 'contains',
6
+ 'minItems', 'maxItems', 'uniqueItems', 'minimum', 'maximum', 'exclusiveMinimum',
7
+ 'exclusiveMaximum', 'multipleOf', 'minLength', 'maxLength', 'pattern', 'format',
8
+ 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', 'title', 'description', 'default',
9
+ 'examples', 'readOnly', 'writeOnly', 'contentMediaType', 'contentEncoding',
10
+ ]);
11
+ export const FIELD_SCHEMA_MAPS = new Set(['properties', 'patternProperties', 'definitions', '$defs']);
12
+ export const FIELD_SCHEMA_ARRAYS = new Set(['allOf', 'anyOf', 'oneOf']);
13
+ export const FIELD_SCHEMA_CHILDREN = new Set([
14
+ 'additionalProperties', 'propertyNames', 'additionalItems', 'contains', 'not', 'if', 'then', 'else',
15
+ ]);
16
+ export const FIELD_SCHEMA_ANNOTATIONS = new Set([
17
+ '$comment', 'title', 'description', 'default', 'examples', 'readOnly', 'writeOnly',
18
+ 'contentMediaType', 'contentEncoding',
19
+ ]);
20
+ export const FIELD_SCHEMA_DRAFTS = new Set([
21
+ 'http://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema#',
22
+ 'https://json-schema.org/draft-07/schema', 'https://json-schema.org/draft-07/schema#',
23
+ ]);
24
+ /** Reserved predicates never replace cfworker's own global format definitions. */
25
+ export const FIELD_FORMAT_PREFIX = 'owlmeans-planning:';
26
+ export const MAX_FIELD_ERRORS = 64;
27
+ export const MAX_FIELD_ERROR_PATH = 512;
28
+ export const REQUIRED_ERROR_PREFIX = 'Instance does not have required property "';
29
+ export const REQUIRED_ERROR_SUFFIX = '".';
30
+ export const FIELD_ERROR_WRAPPERS = new Set(['properties', 'patternProperties', 'items', '$ref', 'allOf', 'if']);
31
+ export const FIELD_ERROR_MESSAGES = {
32
+ required: 'A required field is missing', type: 'The field has an invalid type',
33
+ enum: 'Choose one of the allowed values', const: 'The field must match its fixed value',
34
+ format: 'The field has an invalid format', pattern: 'The field does not match its required pattern',
35
+ additionalProperties: 'This field is not allowed', additionalItems: 'This item is not allowed',
36
+ minimum: 'The value is below its minimum', maximum: 'The value exceeds its maximum',
37
+ exclusiveMinimum: 'The value must exceed its minimum', exclusiveMaximum: 'The value must be below its maximum',
38
+ multipleOf: 'The value is not an allowed multiple', minLength: 'The value is too short',
39
+ maxLength: 'The value is too long', minItems: 'There are too few items', maxItems: 'There are too many items',
40
+ uniqueItems: 'Items must be distinct', minProperties: 'There are too few fields', maxProperties: 'There are too many fields',
41
+ };
42
+ //# sourceMappingURL=consts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../../src/validation/consts.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ export interface PlanningFieldError {
2
+ /** Decoded field path; a required-property error includes the missing property's name. */
3
+ path: string;
4
+ keyword: string;
5
+ schemaPath: string;
6
+ /** A bounded explanation which does not include the submitted value. */
7
+ message: string;
8
+ }
9
+ export interface PlanningFieldValidation {
10
+ valid: boolean;
11
+ errors: PlanningFieldError[];
12
+ }
13
+ export interface PlanningFieldValidator {
14
+ /** Validate without coercion, defaults, removal, code generation or network reference loading. */
15
+ validateFields: (fields: Record<string, unknown>) => PlanningFieldValidation;
16
+ }
17
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/validation/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,uBAAuB;IACtC,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,kBAAkB,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACrC,kGAAkG;IAClG,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,uBAAuB,CAAA;CAC7E"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/validation/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,10 @@
1
+ import type { AnySchema } from 'ajv';
2
+ import type { PlanningFieldValidator } from './validation/types.js';
3
+ /**
4
+ * Interpret the exact served field schema under a strict script-src policy. The vocabulary is
5
+ * Draft 7/$defs/nullable with AJV reference-sibling semantics and its full string-format predicates.
6
+ * Unsupported dialects/extensions, numeric formats and multipleOf schemas refuse
7
+ * during preparation; the server's AJV remains the authoritative validator.
8
+ */
9
+ export declare const makePlanningFieldValidator: (fieldsSchema: AnySchema) => PlanningFieldValidator;
10
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAU,MAAM,KAAK,CAAA;AAK5C,OAAO,KAAK,EAA+C,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAOhH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,iBAAkB,SAAS,KAAG,sBAwHpE,CAAA"}
@@ -0,0 +1,172 @@
1
+ import { dereference, format, validate as interpret, Validator } from '@cfworker/json-schema';
2
+ import formatsPlugin from 'ajv-formats';
3
+ import draft7 from 'ajv/dist/refs/json-schema-draft-07.json';
4
+ import { SchemaInvalid } from '@owlmeans/planning';
5
+ import { FIELD_ERROR_MESSAGES, FIELD_ERROR_WRAPPERS, FIELD_FORMAT_PREFIX, FIELD_SCHEMA_ANNOTATIONS, FIELD_SCHEMA_ARRAYS, FIELD_SCHEMA_CHILDREN, FIELD_SCHEMA_DRAFTS, FIELD_SCHEMA_KEYWORDS, FIELD_SCHEMA_MAPS, MAX_FIELD_ERRORS, MAX_FIELD_ERROR_PATH, REQUIRED_ERROR_PREFIX, REQUIRED_ERROR_SUFFIX, } from './validation/consts.js';
6
+ /**
7
+ * Interpret the exact served field schema under a strict script-src policy. The vocabulary is
8
+ * Draft 7/$defs/nullable with AJV reference-sibling semantics and its full string-format predicates.
9
+ * Unsupported dialects/extensions, numeric formats and multipleOf schemas refuse
10
+ * during preparation; the server's AJV remains the authoritative validator.
11
+ */
12
+ export const makePlanningFieldValidator = (fieldsSchema) => {
13
+ const invalid = (path, keyword) => {
14
+ throw new SchemaInvalid(`client:fields:${keyword}:${path.slice(0, MAX_FIELD_ERROR_PATH)}`);
15
+ };
16
+ const pointer = (part) => part.replaceAll('~', '~0').replaceAll('/', '~1');
17
+ const nodes = new Map();
18
+ const meta = new Validator(structuredClone(draft7), '7', false);
19
+ const prepare = (source, path) => {
20
+ if (typeof source === 'boolean')
21
+ return source;
22
+ if (source == null || typeof source !== 'object' || Array.isArray(source))
23
+ return invalid(path, 'schema');
24
+ const checked = meta.validate(source);
25
+ if (!checked.valid)
26
+ return invalid(`${path}${checked.errors[0]?.instanceLocation.slice(1) ?? ''}`, 'malformed');
27
+ const node = {};
28
+ nodes.set(node, path);
29
+ for (const [key, value] of Object.entries(source)) {
30
+ const next = `${path}/${pointer(key)}`;
31
+ if (!FIELD_SCHEMA_KEYWORDS.has(key) && !key.startsWith('x-'))
32
+ invalid(next, 'unsupported-keyword');
33
+ if (FIELD_SCHEMA_ANNOTATIONS.has(key) || key.startsWith('x-'))
34
+ continue;
35
+ if (key === 'nullable')
36
+ continue;
37
+ if (key === '$schema' && !FIELD_SCHEMA_DRAFTS.has(String(value)))
38
+ invalid(next, 'unsupported-draft');
39
+ // The interpreters disagree on floating tolerance and large/scientific-notation quotients.
40
+ if (key === 'multipleOf')
41
+ invalid(next, 'unsupported-multipleOf');
42
+ if (key === 'format') {
43
+ let definition;
44
+ try {
45
+ definition = formatsPlugin.get(value, 'full');
46
+ }
47
+ catch {
48
+ return invalid(next, 'unsupported-format');
49
+ }
50
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && definition.type === 'number') {
51
+ invalid(next, 'unsupported-numeric-format');
52
+ }
53
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && 'async' in definition && definition.async === true) {
54
+ invalid(next, 'unsupported-async-format');
55
+ }
56
+ const guard = typeof definition === 'object' && !(definition instanceof RegExp) ? definition.validate : definition;
57
+ const predicate = typeof guard === 'string' ? new RegExp(guard, 'u') : guard;
58
+ const name = `${FIELD_FORMAT_PREFIX}${String(value)}`;
59
+ format[name] ??= input => predicate === true || (predicate instanceof RegExp ? predicate.test(input)
60
+ : predicate(input)) === true;
61
+ node.format = name;
62
+ }
63
+ else if (FIELD_SCHEMA_MAPS.has(key)) {
64
+ if (value == null || typeof value !== 'object' || Array.isArray(value))
65
+ invalid(next, 'malformed');
66
+ node[key] = Object.fromEntries(Object.entries(value)
67
+ .map(([name, child]) => [name, prepare(child, `${next}/${pointer(name)}`)]));
68
+ }
69
+ else if (FIELD_SCHEMA_ARRAYS.has(key) || (key === 'items' && Array.isArray(value))) {
70
+ node[key] = value.map((child, index) => prepare(child, `${next}/${index}`));
71
+ }
72
+ else if (FIELD_SCHEMA_CHILDREN.has(key) || key === 'items') {
73
+ node[key] = prepare(value, next);
74
+ }
75
+ else if (key === 'dependencies') {
76
+ ;
77
+ node[key] = Object.fromEntries(Object.entries(value)
78
+ .map(([name, child]) => [name, Array.isArray(child) ? child : prepare(child, `${next}/${pointer(name)}`)]));
79
+ }
80
+ else {
81
+ node[key] = structuredClone(value);
82
+ }
83
+ }
84
+ if ('nullable' in source) {
85
+ const nullable = source.nullable;
86
+ if (typeof nullable !== 'boolean' || node.type == null)
87
+ invalid(`${path}/nullable`, 'malformed');
88
+ const kinds = Array.isArray(node.type) ? node.type : [node.type];
89
+ if (nullable)
90
+ node.type = [...new Set([...kinds, 'null'])];
91
+ else if (kinds.includes('null'))
92
+ invalid(`${path}/nullable`, 'malformed');
93
+ }
94
+ return node;
95
+ };
96
+ const schema = prepare(fieldsSchema, '#');
97
+ let lookup;
98
+ try {
99
+ lookup = dereference(schema);
100
+ }
101
+ catch {
102
+ return invalid('#', 'invalid-reference');
103
+ }
104
+ for (const [node, path] of nodes) {
105
+ if (node.$ref != null && lookup[node.__absolute_ref__] === undefined)
106
+ invalid(`${path}/$ref`, 'missing-reference');
107
+ }
108
+ const complete = new Set();
109
+ const visiting = new Set();
110
+ const checkReferences = (node) => {
111
+ if (typeof node === 'boolean' || complete.has(node))
112
+ return;
113
+ if (visiting.has(node))
114
+ invalid(nodes.get(node) ?? '#', 'cyclic-reference');
115
+ visiting.add(node);
116
+ if (node.$ref != null)
117
+ checkReferences(lookup[node.__absolute_ref__]);
118
+ // These evaluate the same instance; properties/items make productive recursive schemas.
119
+ for (const key of ['allOf', 'anyOf', 'oneOf'])
120
+ node[key]?.forEach(checkReferences);
121
+ for (const key of ['not', 'if', 'then', 'else'])
122
+ if (node[key] != null)
123
+ checkReferences(node[key]);
124
+ for (const child of Object.values(node.dependencies ?? {}))
125
+ if (!Array.isArray(child))
126
+ checkReferences(child);
127
+ visiting.delete(node);
128
+ complete.add(node);
129
+ };
130
+ nodes.forEach((_path, node) => checkReferences(node));
131
+ const fieldError = (error) => {
132
+ const parts = error.instanceLocation.slice(1).replace(/^\//, '').split('/').filter(Boolean)
133
+ .map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'));
134
+ if (error.keyword === 'required' && error.error.startsWith(REQUIRED_ERROR_PREFIX) && error.error.endsWith(REQUIRED_ERROR_SUFFIX)) {
135
+ parts.push(error.error.slice(REQUIRED_ERROR_PREFIX.length, -REQUIRED_ERROR_SUFFIX.length));
136
+ }
137
+ return { path: parts.join('.').slice(0, MAX_FIELD_ERROR_PATH), keyword: error.keyword,
138
+ schemaPath: error.keywordLocation.slice(0, MAX_FIELD_ERROR_PATH),
139
+ message: FIELD_ERROR_MESSAGES[error.keyword] ?? 'The field does not match its definition' };
140
+ };
141
+ const jsonErrors = (value, path, active) => {
142
+ if (value === null || typeof value === 'string' || typeof value === 'boolean'
143
+ || (typeof value === 'number' && Number.isFinite(value)))
144
+ return [];
145
+ if (typeof value !== 'object' || active.has(value))
146
+ return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }];
147
+ const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
148
+ if (!plain)
149
+ return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }];
150
+ active.add(value);
151
+ const errors = Object.entries(value).flatMap(([key, child]) => [
152
+ ...(!Array.isArray(value) && (key.includes('.') || key.includes('$'))
153
+ ? [{ path: [path, key].filter(Boolean).join('.'), keyword: 'field-key', schemaPath: '#', message: 'This field name is not allowed' }] : []),
154
+ ...jsonErrors(child, [path, key].filter(Boolean).join('.'), active),
155
+ ]);
156
+ active.delete(value);
157
+ return errors;
158
+ };
159
+ const validateFields = (fields) => {
160
+ const unsafe = jsonErrors(fields, '', new Set());
161
+ if (unsafe.length > 0)
162
+ return { valid: false, errors: unsafe.slice(0, MAX_FIELD_ERRORS)
163
+ .map(error => ({ ...error, path: error.path.slice(0, MAX_FIELD_ERROR_PATH) })) };
164
+ // Only the guarded Draft 7 vocabulary reaches this engine. This mode, unlike its '7' mode,
165
+ // also enforces sibling keywords beside $ref, as the native planning AJV does.
166
+ const result = interpret(fields, schema, '2019-09', lookup, false);
167
+ const errors = result.errors.filter(error => !FIELD_ERROR_WRAPPERS.has(error.keyword));
168
+ return { valid: result.valid, errors: (errors.length > 0 ? errors : result.errors).slice(0, MAX_FIELD_ERRORS).map(fieldError) };
169
+ };
170
+ return { validateFields };
171
+ };
172
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/client-planning",
3
- "version": "0.1.18-rc.29",
3
+ "version": "0.1.18-rc.30",
4
4
  "license": "MIT",
5
5
  "description": "The client half of OwlMeans planning — the remote planning facade, the state mirror and its commit fold, and the subscribe-then-poll commit wait. React-free: it runs in a browser and in a Node client alike.",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
+ "@cfworker/json-schema": "^4.1.1",
26
27
  "@owlmeans/auth": "^0.1.18-rc.41",
27
28
  "@owlmeans/client-context": "^0.1.18-rc.50",
28
29
  "@owlmeans/client-entrypoint": "^0.1.18-rc.49",
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export type * from './events/types.js'
3
3
  export type * from './helper/types.js'
4
4
  export type * from './sync/types.js'
5
5
  export type * from './lifecycle/types.js'
6
+ export type * from './validation/types.js'
6
7
 
7
8
  export * from './consts.js'
8
9
  export * from './client.js'
@@ -18,3 +19,4 @@ export * from './helper.js'
18
19
  export * from './resources.js'
19
20
  export * from './resource-feed.js'
20
21
  export * from './lifecycle.js'
22
+ export * from './validation.js'
@@ -0,0 +1,41 @@
1
+ /** Draft 7 vocabulary, plus AJV's $defs spelling. Other vocabularies fail closed. */
2
+ export const FIELD_SCHEMA_KEYWORDS = new Set([
3
+ '$id', '$schema', '$ref', '$comment', '$defs', 'definitions', 'type', 'nullable', 'const', 'enum',
4
+ 'required', 'properties', 'patternProperties', 'additionalProperties', 'propertyNames',
5
+ 'minProperties', 'maxProperties', 'dependencies', 'items', 'additionalItems', 'contains',
6
+ 'minItems', 'maxItems', 'uniqueItems', 'minimum', 'maximum', 'exclusiveMinimum',
7
+ 'exclusiveMaximum', 'multipleOf', 'minLength', 'maxLength', 'pattern', 'format',
8
+ 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', 'title', 'description', 'default',
9
+ 'examples', 'readOnly', 'writeOnly', 'contentMediaType', 'contentEncoding',
10
+ ])
11
+ export const FIELD_SCHEMA_MAPS = new Set(['properties', 'patternProperties', 'definitions', '$defs'])
12
+ export const FIELD_SCHEMA_ARRAYS = new Set(['allOf', 'anyOf', 'oneOf'])
13
+ export const FIELD_SCHEMA_CHILDREN = new Set([
14
+ 'additionalProperties', 'propertyNames', 'additionalItems', 'contains', 'not', 'if', 'then', 'else',
15
+ ])
16
+ export const FIELD_SCHEMA_ANNOTATIONS = new Set([
17
+ '$comment', 'title', 'description', 'default', 'examples', 'readOnly', 'writeOnly',
18
+ 'contentMediaType', 'contentEncoding',
19
+ ])
20
+ export const FIELD_SCHEMA_DRAFTS = new Set([
21
+ 'http://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema#',
22
+ 'https://json-schema.org/draft-07/schema', 'https://json-schema.org/draft-07/schema#',
23
+ ])
24
+ /** Reserved predicates never replace cfworker's own global format definitions. */
25
+ export const FIELD_FORMAT_PREFIX = 'owlmeans-planning:'
26
+ export const MAX_FIELD_ERRORS = 64
27
+ export const MAX_FIELD_ERROR_PATH = 512
28
+ export const REQUIRED_ERROR_PREFIX = 'Instance does not have required property "'
29
+ export const REQUIRED_ERROR_SUFFIX = '".'
30
+ export const FIELD_ERROR_WRAPPERS = new Set(['properties', 'patternProperties', 'items', '$ref', 'allOf', 'if'])
31
+ export const FIELD_ERROR_MESSAGES: Readonly<Record<string, string>> = {
32
+ required: 'A required field is missing', type: 'The field has an invalid type',
33
+ enum: 'Choose one of the allowed values', const: 'The field must match its fixed value',
34
+ format: 'The field has an invalid format', pattern: 'The field does not match its required pattern',
35
+ additionalProperties: 'This field is not allowed', additionalItems: 'This item is not allowed',
36
+ minimum: 'The value is below its minimum', maximum: 'The value exceeds its maximum',
37
+ exclusiveMinimum: 'The value must exceed its minimum', exclusiveMaximum: 'The value must be below its maximum',
38
+ multipleOf: 'The value is not an allowed multiple', minLength: 'The value is too short',
39
+ maxLength: 'The value is too long', minItems: 'There are too few items', maxItems: 'There are too many items',
40
+ uniqueItems: 'Items must be distinct', minProperties: 'There are too few fields', maxProperties: 'There are too many fields',
41
+ }
@@ -0,0 +1,19 @@
1
+ export interface PlanningFieldError {
2
+ /** Decoded field path; a required-property error includes the missing property's name. */
3
+ path: string
4
+ keyword: string
5
+ schemaPath: string
6
+ /** A bounded explanation which does not include the submitted value. */
7
+ message: string
8
+ }
9
+
10
+ export interface PlanningFieldValidation {
11
+ valid: boolean
12
+ errors: PlanningFieldError[]
13
+ }
14
+
15
+ export interface PlanningFieldValidator {
16
+ /** Validate without coercion, defaults, removal, code generation or network reference loading. */
17
+ validateFields: (fields: Record<string, unknown>) => PlanningFieldValidation
18
+ }
19
+
@@ -0,0 +1,141 @@
1
+ import { dereference, format, validate as interpret, Validator } from '@cfworker/json-schema'
2
+ import type { OutputUnit, Schema } from '@cfworker/json-schema'
3
+ import type { AnySchema, Format } from 'ajv'
4
+ import formatsPlugin from 'ajv-formats'
5
+ import type { FormatName } from 'ajv-formats'
6
+ import draft7 from 'ajv/dist/refs/json-schema-draft-07.json'
7
+ import { SchemaInvalid } from '@owlmeans/planning'
8
+ import type { PlanningFieldError, PlanningFieldValidation, PlanningFieldValidator } from './validation/types.js'
9
+ import {
10
+ FIELD_ERROR_MESSAGES, FIELD_ERROR_WRAPPERS, FIELD_FORMAT_PREFIX, FIELD_SCHEMA_ANNOTATIONS, FIELD_SCHEMA_ARRAYS,
11
+ FIELD_SCHEMA_CHILDREN, FIELD_SCHEMA_DRAFTS, FIELD_SCHEMA_KEYWORDS, FIELD_SCHEMA_MAPS,
12
+ MAX_FIELD_ERRORS, MAX_FIELD_ERROR_PATH, REQUIRED_ERROR_PREFIX, REQUIRED_ERROR_SUFFIX,
13
+ } from './validation/consts.js'
14
+
15
+ /**
16
+ * Interpret the exact served field schema under a strict script-src policy. The vocabulary is
17
+ * Draft 7/$defs/nullable with AJV reference-sibling semantics and its full string-format predicates.
18
+ * Unsupported dialects/extensions, numeric formats and multipleOf schemas refuse
19
+ * during preparation; the server's AJV remains the authoritative validator.
20
+ */
21
+ export const makePlanningFieldValidator = (fieldsSchema: AnySchema): PlanningFieldValidator => {
22
+ const invalid = (path: string, keyword: string): never => {
23
+ throw new SchemaInvalid(`client:fields:${keyword}:${path.slice(0, MAX_FIELD_ERROR_PATH)}`)
24
+ }
25
+ const pointer = (part: string): string => part.replaceAll('~', '~0').replaceAll('/', '~1')
26
+ const nodes = new Map<Schema, string>()
27
+ const meta = new Validator(structuredClone(draft7) as Schema, '7', false)
28
+
29
+ const prepare = (source: unknown, path: string): Schema | boolean => {
30
+ if (typeof source === 'boolean') return source
31
+ if (source == null || typeof source !== 'object' || Array.isArray(source)) return invalid(path, 'schema')
32
+ const checked = meta.validate(source)
33
+ if (!checked.valid) return invalid(`${path}${checked.errors[0]?.instanceLocation.slice(1) ?? ''}`, 'malformed')
34
+ const node: Schema = {}
35
+ nodes.set(node, path)
36
+ for (const [key, value] of Object.entries(source)) {
37
+ const next = `${path}/${pointer(key)}`
38
+ if (!FIELD_SCHEMA_KEYWORDS.has(key) && !key.startsWith('x-')) invalid(next, 'unsupported-keyword')
39
+ if (FIELD_SCHEMA_ANNOTATIONS.has(key) || key.startsWith('x-')) continue
40
+ if (key === 'nullable') continue
41
+ if (key === '$schema' && !FIELD_SCHEMA_DRAFTS.has(String(value))) invalid(next, 'unsupported-draft')
42
+ // The interpreters disagree on floating tolerance and large/scientific-notation quotients.
43
+ if (key === 'multipleOf') invalid(next, 'unsupported-multipleOf')
44
+ if (key === 'format') {
45
+ let definition: Format
46
+ try { definition = formatsPlugin.get(value as FormatName, 'full') }
47
+ catch { return invalid(next, 'unsupported-format') }
48
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && definition.type === 'number') {
49
+ invalid(next, 'unsupported-numeric-format')
50
+ }
51
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && 'async' in definition && definition.async === true) {
52
+ invalid(next, 'unsupported-async-format')
53
+ }
54
+ const guard = typeof definition === 'object' && !(definition instanceof RegExp) ? definition.validate : definition
55
+ const predicate = typeof guard === 'string' ? new RegExp(guard, 'u') : guard
56
+ const name = `${FIELD_FORMAT_PREFIX}${String(value)}`
57
+ format[name] ??= input => predicate === true || (predicate instanceof RegExp ? predicate.test(input)
58
+ : (predicate as (value: string) => boolean)(input)) === true
59
+ node.format = name
60
+ } else if (FIELD_SCHEMA_MAPS.has(key)) {
61
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) invalid(next, 'malformed')
62
+ node[key] = Object.fromEntries(Object.entries(value as Record<string, unknown>)
63
+ .map(([name, child]) => [name, prepare(child, `${next}/${pointer(name)}`)]))
64
+ } else if (FIELD_SCHEMA_ARRAYS.has(key) || (key === 'items' && Array.isArray(value))) {
65
+ node[key] = (value as unknown[]).map((child, index) => prepare(child, `${next}/${index}`))
66
+ } else if (FIELD_SCHEMA_CHILDREN.has(key) || key === 'items') {
67
+ node[key] = prepare(value, next)
68
+ } else if (key === 'dependencies') {
69
+ ;(node as Record<string, unknown>)[key] = Object.fromEntries(Object.entries(value as Record<string, unknown>)
70
+ .map(([name, child]) => [name, Array.isArray(child) ? child : prepare(child, `${next}/${pointer(name)}`)]))
71
+ } else { node[key] = structuredClone(value) }
72
+ }
73
+ if ('nullable' in source) {
74
+ const nullable = (source as Schema).nullable
75
+ if (typeof nullable !== 'boolean' || node.type == null) invalid(`${path}/nullable`, 'malformed')
76
+ const kinds = Array.isArray(node.type) ? node.type : [node.type!]
77
+ if (nullable) node.type = [...new Set([...kinds, 'null' as const])]
78
+ else if (kinds.includes('null')) invalid(`${path}/nullable`, 'malformed')
79
+ }
80
+ return node
81
+ }
82
+ const schema = prepare(fieldsSchema, '#')
83
+ let lookup: Record<string, Schema | boolean>
84
+ try { lookup = dereference(schema) }
85
+ catch { return invalid('#', 'invalid-reference') }
86
+ for (const [node, path] of nodes) {
87
+ if (node.$ref != null && lookup[node.__absolute_ref__!] === undefined) invalid(`${path}/$ref`, 'missing-reference')
88
+ }
89
+ const complete = new Set<Schema>()
90
+ const visiting = new Set<Schema>()
91
+ const checkReferences = (node: Schema | boolean): void => {
92
+ if (typeof node === 'boolean' || complete.has(node)) return
93
+ if (visiting.has(node)) invalid(nodes.get(node) ?? '#', 'cyclic-reference')
94
+ visiting.add(node)
95
+ if (node.$ref != null) checkReferences(lookup[node.__absolute_ref__!]!)
96
+ // These evaluate the same instance; properties/items make productive recursive schemas.
97
+ for (const key of ['allOf', 'anyOf', 'oneOf']) (node[key] as Array<Schema | boolean> | undefined)?.forEach(checkReferences)
98
+ for (const key of ['not', 'if', 'then', 'else']) if (node[key] != null) checkReferences(node[key])
99
+ for (const child of Object.values(node.dependencies ?? {})) if (!Array.isArray(child)) checkReferences(child)
100
+ visiting.delete(node)
101
+ complete.add(node)
102
+ }
103
+ nodes.forEach((_path, node) => checkReferences(node))
104
+
105
+ const fieldError = (error: OutputUnit): PlanningFieldError => {
106
+ const parts = error.instanceLocation.slice(1).replace(/^\//, '').split('/').filter(Boolean)
107
+ .map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'))
108
+ if (error.keyword === 'required' && error.error.startsWith(REQUIRED_ERROR_PREFIX) && error.error.endsWith(REQUIRED_ERROR_SUFFIX)) {
109
+ parts.push(error.error.slice(REQUIRED_ERROR_PREFIX.length, -REQUIRED_ERROR_SUFFIX.length))
110
+ }
111
+ return { path: parts.join('.').slice(0, MAX_FIELD_ERROR_PATH), keyword: error.keyword,
112
+ schemaPath: error.keywordLocation.slice(0, MAX_FIELD_ERROR_PATH),
113
+ message: FIELD_ERROR_MESSAGES[error.keyword] ?? 'The field does not match its definition' }
114
+ }
115
+ const jsonErrors = (value: unknown, path: string, active: Set<object>): PlanningFieldError[] => {
116
+ if (value === null || typeof value === 'string' || typeof value === 'boolean'
117
+ || (typeof value === 'number' && Number.isFinite(value))) return []
118
+ if (typeof value !== 'object' || active.has(value)) return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }]
119
+ const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null
120
+ if (!plain) return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }]
121
+ active.add(value)
122
+ const errors = Object.entries(value).flatMap(([key, child]) => [
123
+ ...(!Array.isArray(value) && (key.includes('.') || key.includes('$'))
124
+ ? [{ path: [path, key].filter(Boolean).join('.'), keyword: 'field-key', schemaPath: '#', message: 'This field name is not allowed' }] : []),
125
+ ...jsonErrors(child, [path, key].filter(Boolean).join('.'), active),
126
+ ])
127
+ active.delete(value)
128
+ return errors
129
+ }
130
+ const validateFields = (fields: Record<string, unknown>): PlanningFieldValidation => {
131
+ const unsafe = jsonErrors(fields, '', new Set())
132
+ if (unsafe.length > 0) return { valid: false, errors: unsafe.slice(0, MAX_FIELD_ERRORS)
133
+ .map(error => ({ ...error, path: error.path.slice(0, MAX_FIELD_ERROR_PATH) })) }
134
+ // Only the guarded Draft 7 vocabulary reaches this engine. This mode, unlike its '7' mode,
135
+ // also enforces sibling keywords beside $ref, as the native planning AJV does.
136
+ const result = interpret(fields, schema, '2019-09', lookup, false)
137
+ const errors = result.errors.filter(error => !FIELD_ERROR_WRAPPERS.has(error.keyword))
138
+ return { valid: result.valid, errors: (errors.length > 0 ? errors : result.errors).slice(0, MAX_FIELD_ERRORS).map(fieldError) }
139
+ }
140
+ return { validateFields }
141
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnySchema } from 'ajv'
3
+ import { makeAjv, SchemaInvalid } from '@owlmeans/planning'
4
+ import { makePlanningFieldValidator } from '../src/index.js'
5
+
6
+ describe('@owlmeans/client-planning — CSP field validation', () => {
7
+ test('the exact nested fields validate without modifying, coercing, flattening or defaulting them', () => {
8
+ const schema = { type: 'object', properties: {
9
+ estimatedValue: { type: 'object', properties: { amount: { type: 'number', minimum: 0 }, currency: { type: 'string', enum: ['USD', 'EUR'] } },
10
+ required: ['amount', 'currency'], additionalProperties: false },
11
+ nextContactDate: { type: 'string', format: 'date' }, priority: { type: 'string', enum: ['low', 'normal', 'high'] },
12
+ }, required: ['estimatedValue', 'nextContactDate'], additionalProperties: false }
13
+ const copy = structuredClone(schema)
14
+ const validator = makePlanningFieldValidator(schema)
15
+ const valid = { estimatedValue: { amount: 12345, currency: 'USD' }, nextContactDate: '2027-03-15', priority: 'normal' }
16
+ const payload = structuredClone(valid)
17
+ expect(validator.validateFields(valid)).toEqual({ valid: true, errors: [] })
18
+ expect(validator.validateFields({ estimatedValue: valid.estimatedValue, nextContactDate: valid.nextContactDate }).valid).toBe(true)
19
+ const invalid = [ {}, { ...valid, estimatedValue: 12345 }, { ...valid, estimatedValue: { amount: 2 } },
20
+ { ...valid, estimatedValue: { amount: '12345', currency: 'USD' } }, { ...valid, estimatedValue: { amount: -1, currency: 'USD' } },
21
+ { ...valid, priority: 'urgent' }, { ...valid, nextContactDate: '2027-02-30' },
22
+ { ...valid, nextContactDate: '2027-03-15T00:00:00Z' }, { ...valid, extra: true } ]
23
+ const native = makeAjv().compile(schema)
24
+ for (const fields of invalid) {
25
+ const checked = validator.validateFields(fields)
26
+ expect(checked.valid).toBe(native(fields) === true)
27
+ expect(checked.valid).toBe(false)
28
+ expect(checked.errors.length).toBeGreaterThan(0)
29
+ expect(checked.errors.every(error => error.message.length < 100)).toBe(true)
30
+ }
31
+ expect(validator.validateFields({ ...valid, estimatedValue: { amount: 2 } }).errors).toContainEqual({
32
+ path: 'estimatedValue.currency', keyword: 'required', schemaPath: '#/properties/estimatedValue/required', message: 'A required field is missing',
33
+ })
34
+ expect(schema).toEqual(copy)
35
+ expect(valid).toEqual(payload)
36
+ })
37
+
38
+ test('supported Draft 7 predicates agree with native planning AJV, including references, compositions and full formats', () => {
39
+ const cases: Array<{ schema: AnySchema, values: unknown[] }> = [
40
+ { schema: { type: ['number', 'null'], minimum: 0, maximum: 7, exclusiveMinimum: -1, exclusiveMaximum: 8 }, values: [null, 0, 7, -1, 8, '3'] },
41
+ { schema: { type: 'integer', minimum: -3, maximum: 3 }, values: [-3, 0, 3, 1.99999999, 6.00000001, 4] },
42
+ { schema: { type: 'string', minLength: 2, maxLength: 3, pattern: '^.+' }, values: ['ab', 'a😀', '😀', 'abcd', '', 2] },
43
+ { schema: { type: 'array', items: [{ type: 'string' }, { type: 'integer' }], additionalItems: false }, values: [[], ['a'], ['a', 2], ['a', '2'], ['a', 2, 3]] },
44
+ { schema: { type: 'array', items: { type: 'number' }, minItems: 1, maxItems: 3, uniqueItems: true, contains: { const: 2 } }, values: [[1, 2], [2], [], [1], [2, 2], [1, 2, 3, 4], ['2']] },
45
+ { schema: { type: 'object', patternProperties: { '^a': { type: 'number' } }, additionalProperties: false, minProperties: 1, maxProperties: 2 },
46
+ values: [{ abc: 1 }, { abc: '1' }, {}, { abc: 1, other: 2 }, { a: 1, ab: 2, abc: 3 }] },
47
+ { schema: { type: 'object', propertyNames: { pattern: '^[a-z]+$' }, dependencies: { a: ['b'], c: { required: ['d'] } } },
48
+ values: [{}, { a: 1, b: 2 }, { a: 1 }, { c: 1 }, { c: 1, d: 2 }, { A: 1 }] },
49
+ { schema: { allOf: [{ type: 'number' }, { minimum: 2 }], not: { const: 3 } }, values: [2, 4, 1, 3, '2'] },
50
+ { schema: { anyOf: [{ type: 'string', pattern: '^a' }, { type: 'integer' }] }, values: ['abc', 2, 'xyz', 1.5, null] },
51
+ { schema: { oneOf: [{ type: 'number', minimum: 1 }, { type: 'number', maximum: 3 }] }, values: [0, 4, 2, '2'] },
52
+ { schema: { if: { type: 'string' }, then: { minLength: 2 }, else: { type: 'integer' } }, values: ['ab', 2, 'a', 1.5, null] },
53
+ { schema: { $defs: { n: { type: 'number' } }, $ref: '#/$defs/n', minimum: 5 }, values: [7, 3, '7'] },
54
+ { schema: { $id: 'https://planning.test/fields', definitions: { n: { $id: 'number', type: 'number' } }, properties: { a: { $ref: 'number' } } },
55
+ values: [{ a: 2 }, { a: '2' }, {}] },
56
+ { schema: { definitions: { blocked: false }, properties: { a: { $ref: '#/definitions/blocked' } } }, values: [{}, { a: 1 }] },
57
+ { schema: { type: 'object', properties: { value: { type: 'number' }, next: { $ref: '#' } }, required: ['value'] },
58
+ values: [{ value: 1 }, { value: 1, next: { value: 2 } }, { value: 1, next: {} }] },
59
+ { schema: { type: 'object', properties: { a: { type: 'number', default: 7 } }, required: ['a'] }, values: [{}, { a: 3 }, { a: '3' }] },
60
+ { schema: { type: 'object', properties: { a: { type: 'string', nullable: true, minLength: 2, format: 'email' } } },
61
+ values: [{}, { a: null }, { a: 'a@example.test' }, { a: 'a' }, { a: 3 }] },
62
+ { schema: { type: 'string', nullable: true, enum: ['a'] }, values: ['a', null, 'b'] },
63
+ { schema: { type: 'string', nullable: true, const: 'a' }, values: ['a', null, 'b'] },
64
+ { schema: { type: ['string', 'null'], nullable: true }, values: ['a', null, 3] },
65
+ { schema: { type: 'string', nullable: false }, values: ['a', null, 3] },
66
+ { schema: true, values: [1, 'a', null] }, { schema: false, values: [1, 'a', null] },
67
+ ]
68
+ const formats = [
69
+ ['date', ['2024-02-29', '2027-03-15', '2027-02-29', '2027-04-31', '2027-03-15T00:00:00Z']],
70
+ ['time', ['12:00:00Z', '23:59:60Z', '12:00:00', '12:00:00+99:00', '25:00:00Z']],
71
+ ['date-time', ['2027-03-15T12:00:00Z', '2027-03-15 12:00:00Z', '2027-02-30T12:00:00Z', '2027-03-15T12:00:00+99:00']],
72
+ ['email', ['a@example.test', 'a@localhost', '.a@example.test', 'a..b@example.test']],
73
+ ['ipv4', ['127.0.0.1', '01.2.3.4', '256.0.0.1']], ['uuid', ['123e4567-e89b-12d3-a456-426614174000', 'bad']],
74
+ ['uri', ['https://planning.test/a', 'urn:planning:test', '/relative']],
75
+ ['iso-time', ['12:00:00', '12:00:00Z', '25:00:00']], ['duration', ['P1D', 'P1W', 'P', 'P1.5D']],
76
+ ] as const
77
+ for (const [name, values] of formats) cases.push({ schema: { type: 'string', format: name }, values: [...values] })
78
+ for (const { schema, values } of cases) {
79
+ const native = makeAjv().compile(schema)
80
+ const client = makePlanningFieldValidator(schema)
81
+ for (const value of values) {
82
+ // Untyped JSON can reach a validator with the wrong root kind; it must still refuse it.
83
+ expect(client.validateFields(value as Record<string, unknown>).valid).toBe(native(value) === true)
84
+ }
85
+ }
86
+ })
87
+
88
+ test('malformed, unresolved and unsupported schemas fail closed at preparation, including dormant branches', () => {
89
+ const schemas: AnySchema[] = [
90
+ { properties: { optional: { $ref: 'https://missing.test/private?credential=never-print' } } },
91
+ { if: false, then: { $ref: '#/definitions/missing' } }, { $ref: '#' },
92
+ { $schema: 'https://json-schema.org/draft/2020-12/schema' }, { type: 'number', multipleOf: 0.1 },
93
+ { type: 'integer', multipleOf: 2 }, { type: 'integer', multipleOf: 1 },
94
+ { nullable: true }, { type: 'string', nullable: 'true' }, { type: ['string', 'null'], nullable: false },
95
+ { type: 'string', format: 'made-up' }, { type: 'number', format: 'int32' },
96
+ { type: 'string', format: 'date', formatMinimum: '2027-01-01' }, { unevaluatedProperties: false },
97
+ { required: 'field' }, { type: 'object', properties: { a: { minLength: -1 } } },
98
+ { $defs: { invalid: { type: 'invented' } } }, { $defs: null }, { $defs: [] }, { pattern: '[' }, { enum: [] },
99
+ ]
100
+ for (const schema of schemas) expect(() => makePlanningFieldValidator(schema)).toThrow(SchemaInvalid)
101
+ try { makePlanningFieldValidator(schemas[0]!) } catch (error) {
102
+ expect(String(error)).toContain('missing-reference:#/properties/optional/$ref')
103
+ expect(String(error)).not.toContain('credential')
104
+ expect(String(error)).not.toContain('https://missing')
105
+ }
106
+ const schema = { type: 'object', properties: { a: { type: 'number' } }, 'x-nativekit': { source: 'preserved' } }
107
+ expect(makePlanningFieldValidator(schema).validateFields({ a: 3 }).valid).toBe(true)
108
+ expect(schema['x-nativekit']).toEqual({ source: 'preserved' })
109
+ })
110
+
111
+ test('unsafe keys and non-JSON values refuse with bounded field errors, while required names decode correctly', () => {
112
+ const validate = makePlanningFieldValidator({ type: 'object', properties: { nested: { type: 'object', required: ['a/b~c'] } } }).validateFields
113
+ expect(validate({ nested: {} }).errors[0]?.path).toBe('nested.a/b~c')
114
+ for (const value of [NaN, Infinity, undefined, 2n, new Date(), () => 3]) expect(validate({ nested: { value } }).valid).toBe(false)
115
+ expect(validate({ nested: { 'unsafe.key': 3 } }).errors.some(error => error.keyword === 'field-key')).toBe(true)
116
+ const cycle: Record<string, unknown> = {}; cycle.self = cycle
117
+ expect(validate(cycle).valid).toBe(false)
118
+ const longKey = 'field'.repeat(200)
119
+ const unsafe = validate({ [longKey]: undefined, [`${longKey}.nested`]: 3 })
120
+ expect(unsafe.valid).toBe(false)
121
+ expect(unsafe.errors).toHaveLength(2)
122
+ expect(unsafe.errors.every(error => error.path.length <= 512)).toBe(true)
123
+ const errors = makePlanningFieldValidator({ type: 'object', required: Array.from({ length: 100 }, (_, index) => `field${index}`) }).validateFields({})
124
+ expect(errors.valid).toBe(false)
125
+ expect(errors.errors).toHaveLength(64)
126
+ })
127
+ })