@gregiteen/ssss-cli 0.14.2 → 0.14.3

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.
@@ -1,22 +1,34 @@
1
1
  /**
2
- * Zero-dependency JSON Schema validator for SSSS core and bundle schemas.
2
+ * Zero-dependency JSON Schema Draft 2020-12 validator for SSSS core, extension,
3
+ * and bundle schemas.
3
4
  *
4
- * Implements validation for standard JSON Schema Draft 2020-12 constructs
5
- * used by SSSS schemas:
5
+ * Implements validation for JSON Schema Draft 2020-12 constructs used across SSSS:
6
6
  * - type (single or union)
7
- * - properties & additionalProperties
7
+ * - const & enum
8
+ * - properties, additionalProperties & propertyNames
8
9
  * - required
9
- * - items
10
- * - enum
11
- * - pattern
12
- * - $ref (local root-anchored pointers: #/$defs/...)
10
+ * - items, minItems, maxItems & uniqueItems
11
+ * - minLength, maxLength & pattern (Unicode-aware)
12
+ * - minimum & maximum
13
+ * - allOf, anyOf & oneOf
14
+ * - if / then / else
15
+ * - $ref (local root-anchored pointers `#/...` evaluated alongside sibling keywords)
13
16
  */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const SCHEMAS_DIR = path.resolve(__dirname, '..', 'schemas');
23
+
24
+ let cachedCoreSchema = null;
25
+ let cachedBundleSchema = null;
14
26
 
15
27
  function resolveRef(root, ref) {
16
- if (!ref.startsWith('#/')) {
28
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) {
17
29
  throw new Error(`Only local root-anchored $ref pointers are supported: ${ref}`);
18
30
  }
19
- const parts = ref.slice(2).split('/');
31
+ const parts = ref.slice(2).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
20
32
  let current = root;
21
33
  for (const part of parts) {
22
34
  if (current == null || typeof current !== 'object') {
@@ -24,6 +36,9 @@ function resolveRef(root, ref) {
24
36
  }
25
37
  current = current[part];
26
38
  }
39
+ if (current === undefined) {
40
+ throw new Error(`Unable to resolve $ref pointer: ${ref}`);
41
+ }
27
42
  return current;
28
43
  }
29
44
 
@@ -51,23 +66,78 @@ function matchType(value, expectedType) {
51
66
  }
52
67
  }
53
68
 
69
+ function itemKey(item) {
70
+ if (item === null) return 'null:null';
71
+ if (typeof item === 'object') return `json:${JSON.stringify(item)}`;
72
+ return `${typeof item}:${String(item)}`;
73
+ }
74
+
54
75
  export function validateJsonSchema(data, schema, options = {}) {
55
76
  const root = options.rootSchema || schema;
56
77
  const errors = [];
57
78
 
58
- function validate(value, curSchema, path) {
79
+ function validate(value, curSchema, curPath, seenRefs = new Set()) {
59
80
  if (!curSchema || typeof curSchema !== 'object') return;
60
81
 
61
82
  if (curSchema.$ref) {
62
- const resolved = resolveRef(root, curSchema.$ref);
63
- validate(value, resolved, path);
64
- return;
83
+ if (!seenRefs.has(curSchema.$ref)) {
84
+ const nextSeen = new Set(seenRefs);
85
+ nextSeen.add(curSchema.$ref);
86
+ const resolved = resolveRef(root, curSchema.$ref);
87
+ validate(value, resolved, curPath, nextSeen);
88
+ }
89
+ }
90
+
91
+ if (Array.isArray(curSchema.allOf)) {
92
+ for (const subSchema of curSchema.allOf) {
93
+ validate(value, subSchema, curPath, seenRefs);
94
+ }
95
+ }
96
+
97
+ if (Array.isArray(curSchema.anyOf)) {
98
+ const matched = curSchema.anyOf.some(
99
+ (subSchema) => validateJsonSchema(value, subSchema, { rootSchema: root }).valid
100
+ );
101
+ if (!matched) {
102
+ errors.push({
103
+ path: curPath,
104
+ message: 'Value does not match any allowed schema in anyOf.',
105
+ });
106
+ }
107
+ }
108
+
109
+ if (Array.isArray(curSchema.oneOf)) {
110
+ const matchCount = curSchema.oneOf.filter(
111
+ (subSchema) => validateJsonSchema(value, subSchema, { rootSchema: root }).valid
112
+ ).length;
113
+ if (matchCount !== 1) {
114
+ errors.push({
115
+ path: curPath,
116
+ message: `Expected value to match exactly one schema in oneOf, matched ${matchCount}.`,
117
+ });
118
+ }
119
+ }
120
+
121
+ if (curSchema.if !== undefined && (curSchema.then !== undefined || curSchema.else !== undefined)) {
122
+ const branch = validateJsonSchema(value, curSchema.if, { rootSchema: root }).valid
123
+ ? curSchema.then
124
+ : curSchema.else;
125
+ if (branch !== undefined) validate(value, branch, curPath, seenRefs);
126
+ }
127
+
128
+ if (curSchema.const !== undefined) {
129
+ if (JSON.stringify(value) !== JSON.stringify(curSchema.const)) {
130
+ errors.push({
131
+ path: curPath,
132
+ message: `Expected constant ${JSON.stringify(curSchema.const)}, but received ${JSON.stringify(value)}.`,
133
+ });
134
+ }
65
135
  }
66
136
 
67
137
  if (curSchema.type !== undefined) {
68
138
  if (!matchType(value, curSchema.type)) {
69
139
  errors.push({
70
- path,
140
+ path: curPath,
71
141
  message: `Expected type ${JSON.stringify(curSchema.type)}, but received ${Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value}.`,
72
142
  });
73
143
  return;
@@ -76,17 +146,54 @@ export function validateJsonSchema(data, schema, options = {}) {
76
146
 
77
147
  if (curSchema.enum !== undefined && !curSchema.enum.includes(value)) {
78
148
  errors.push({
79
- path,
149
+ path: curPath,
80
150
  message: `Value ${JSON.stringify(value)} is not in enum: ${curSchema.enum.map((e) => JSON.stringify(e)).join(', ')}.`,
81
151
  });
82
152
  }
83
153
 
84
- if (typeof value === 'string' && curSchema.pattern !== undefined) {
85
- const reg = new RegExp(curSchema.pattern);
86
- if (!reg.test(value)) {
154
+ if (typeof value === 'string') {
155
+ if (curSchema.minLength !== undefined && value.length < curSchema.minLength) {
156
+ errors.push({
157
+ path: curPath,
158
+ message: `String length ${value.length} is less than minLength ${curSchema.minLength}.`,
159
+ });
160
+ }
161
+ if (curSchema.maxLength !== undefined && value.length > curSchema.maxLength) {
87
162
  errors.push({
88
- path,
89
- message: `String does not match pattern ${curSchema.pattern}.`,
163
+ path: curPath,
164
+ message: `String length ${value.length} exceeds maxLength ${curSchema.maxLength}.`,
165
+ });
166
+ }
167
+ if (curSchema.pattern !== undefined) {
168
+ let reg;
169
+ try {
170
+ reg = new RegExp(curSchema.pattern, 'u');
171
+ } catch (err) {
172
+ errors.push({
173
+ path: curPath,
174
+ message: `Invalid schema regular expression ${curSchema.pattern}: ${err.message}`,
175
+ });
176
+ }
177
+ if (reg && !reg.test(value)) {
178
+ errors.push({
179
+ path: curPath,
180
+ message: `String does not match pattern ${curSchema.pattern}.`,
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ if (typeof value === 'number' && !Number.isNaN(value)) {
187
+ if (curSchema.minimum !== undefined && value < curSchema.minimum) {
188
+ errors.push({
189
+ path: curPath,
190
+ message: `Value ${value} is less than minimum ${curSchema.minimum}.`,
191
+ });
192
+ }
193
+ if (curSchema.maximum !== undefined && value > curSchema.maximum) {
194
+ errors.push({
195
+ path: curPath,
196
+ message: `Value ${value} exceeds maximum ${curSchema.maximum}.`,
90
197
  });
91
198
  }
92
199
  }
@@ -96,32 +203,76 @@ export function validateJsonSchema(data, schema, options = {}) {
96
203
  for (const reqKey of curSchema.required) {
97
204
  if (value[reqKey] === undefined) {
98
205
  errors.push({
99
- path: path ? `${path}.${reqKey}` : reqKey,
206
+ path: curPath ? `${curPath}.${reqKey}` : reqKey,
100
207
  message: `Missing required property '${reqKey}'.`,
101
208
  });
102
209
  }
103
210
  }
104
211
  }
105
212
 
106
- const definedKeys = new Set(Object.keys(curSchema.properties || {}));
107
- for (const [key, val] of Object.entries(value)) {
108
- const subPath = path ? `${path}.${key}` : key;
109
- if (definedKeys.has(key)) {
110
- validate(val, curSchema.properties[key], subPath);
111
- } else if (curSchema.additionalProperties === false) {
112
- errors.push({
113
- path: subPath,
114
- message: `Unexpected property '${key}' not defined in schema.`,
115
- });
116
- } else if (typeof curSchema.additionalProperties === 'object' && curSchema.additionalProperties !== null) {
117
- validate(val, curSchema.additionalProperties, subPath);
213
+ if (curSchema.propertyNames && typeof curSchema.propertyNames === 'object') {
214
+ for (const key of Object.keys(value)) {
215
+ const keyPath = curPath ? `${curPath}.${key}` : key;
216
+ const keyCheck = validateJsonSchema(key, curSchema.propertyNames, { rootSchema: root });
217
+ if (!keyCheck.valid) {
218
+ for (const err of keyCheck.errors) {
219
+ errors.push({
220
+ path: keyPath,
221
+ message: `Invalid property name '${key}': ${err.message}`,
222
+ });
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ if (curSchema.properties !== undefined || curSchema.additionalProperties !== undefined) {
229
+ const definedKeys = new Set(Object.keys(curSchema.properties || {}));
230
+ for (const [key, val] of Object.entries(value)) {
231
+ const subPath = curPath ? `${curPath}.${key}` : key;
232
+ if (definedKeys.has(key)) {
233
+ validate(val, curSchema.properties[key], subPath);
234
+ } else if (curSchema.additionalProperties === false) {
235
+ errors.push({
236
+ path: subPath,
237
+ message: `Unexpected property '${key}' not defined in schema.`,
238
+ });
239
+ } else if (typeof curSchema.additionalProperties === 'object' && curSchema.additionalProperties !== null) {
240
+ validate(val, curSchema.additionalProperties, subPath);
241
+ }
118
242
  }
119
243
  }
120
244
  }
121
245
 
122
- if (Array.isArray(value) && curSchema.items) {
123
- for (let i = 0; i < value.length; i++) {
124
- validate(value[i], curSchema.items, `${path}[${i}]`);
246
+ if (Array.isArray(value)) {
247
+ if (curSchema.minItems !== undefined && value.length < curSchema.minItems) {
248
+ errors.push({
249
+ path: curPath,
250
+ message: `Array length ${value.length} is less than minItems ${curSchema.minItems}.`,
251
+ });
252
+ }
253
+ if (curSchema.maxItems !== undefined && value.length > curSchema.maxItems) {
254
+ errors.push({
255
+ path: curPath,
256
+ message: `Array length ${value.length} exceeds maxItems ${curSchema.maxItems}.`,
257
+ });
258
+ }
259
+ if (curSchema.uniqueItems === true) {
260
+ const seen = new Set();
261
+ for (let i = 0; i < value.length; i++) {
262
+ const k = itemKey(value[i]);
263
+ if (seen.has(k)) {
264
+ errors.push({
265
+ path: `${curPath}[${i}]`,
266
+ message: `Duplicate array item ${JSON.stringify(value[i])} violates uniqueItems.`,
267
+ });
268
+ }
269
+ seen.add(k);
270
+ }
271
+ }
272
+ if (curSchema.items) {
273
+ for (let i = 0; i < value.length; i++) {
274
+ validate(value[i], curSchema.items, `${curPath}[${i}]`);
275
+ }
125
276
  }
126
277
  }
127
278
  }
@@ -132,3 +283,33 @@ export function validateJsonSchema(data, schema, options = {}) {
132
283
  errors,
133
284
  };
134
285
  }
286
+
287
+ /** Return the parsed canonical Core Registry JSON Schema (`schemas/core-registry.schema.json`). */
288
+ export function getCoreRegistrySchema() {
289
+ if (!cachedCoreSchema) {
290
+ cachedCoreSchema = JSON.parse(
291
+ fs.readFileSync(path.join(SCHEMAS_DIR, 'core-registry.schema.json'), 'utf8')
292
+ );
293
+ }
294
+ return cachedCoreSchema;
295
+ }
296
+
297
+ /** Return the parsed canonical Bundle JSON Schema (`schemas/bundle.schema.json`). */
298
+ export function getBundleSchema() {
299
+ if (!cachedBundleSchema) {
300
+ cachedBundleSchema = JSON.parse(
301
+ fs.readFileSync(path.join(SCHEMAS_DIR, 'bundle.schema.json'), 'utf8')
302
+ );
303
+ }
304
+ return cachedBundleSchema;
305
+ }
306
+
307
+ /** Validate a core or extension registry object against `schemas/core-registry.schema.json`. */
308
+ export function validateRegistryAgainstSchema(registry) {
309
+ return validateJsonSchema(registry, getCoreRegistrySchema());
310
+ }
311
+
312
+ /** Validate a `.ucw` bundle object against `schemas/bundle.schema.json`. */
313
+ export function validateBundleAgainstSchema(bundle) {
314
+ return validateJsonSchema(bundle, getBundleSchema());
315
+ }
package/types/kernel.d.ts CHANGED
@@ -18,6 +18,7 @@ export interface SsssCommand {
18
18
  content?: string;
19
19
  patches?: Record<string, unknown> & { __body__?: string };
20
20
  lease_id?: string;
21
+ expected_before_hash?: string;
21
22
  dry_run?: boolean;
22
23
  }
23
24
 
@@ -0,0 +1,24 @@
1
+ export interface SchemaValidationError {
2
+ path: string;
3
+ message: string;
4
+ }
5
+
6
+ export interface SchemaValidationResult {
7
+ valid: boolean;
8
+ errors: SchemaValidationError[];
9
+ }
10
+
11
+ export interface ValidateJsonSchemaOptions {
12
+ rootSchema?: Record<string, unknown>;
13
+ }
14
+
15
+ export function validateJsonSchema(
16
+ data: unknown,
17
+ schema: Record<string, unknown>,
18
+ options?: ValidateJsonSchemaOptions
19
+ ): SchemaValidationResult;
20
+
21
+ export function getCoreRegistrySchema(): Record<string, unknown>;
22
+ export function getBundleSchema(): Record<string, unknown>;
23
+ export function validateRegistryAgainstSchema(registry: unknown): SchemaValidationResult;
24
+ export function validateBundleAgainstSchema(bundle: unknown): SchemaValidationResult;
@@ -1,5 +0,0 @@
1
- {
2
- "$schema": "https://ssss.dev/registry/v1",
3
- "description": "Extensions composed when a caller does not scope a registry load. Extensions NOT listed here are product-specific: a host loads one only when it is operating on that product's vault, passing the extension name explicitly (`ssss export --ext <name>`). This keeps two unrelated products from colliding on a shared primitive name, which §5.6 makes a load-time error.",
4
- "default_extensions": ["festech"]
5
- }