@gregiteen/ssss-cli 0.8.0 → 0.9.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 +11 -12
- package/VERSION +1 -1
- package/conformance/README.md +1 -1
- package/conformance/fixtures.json +4 -48
- package/conformance/reference-bundle.ucw.json +4 -21
- package/docs/help/bundle.md +2 -2
- package/docs/help/overview.md +4 -4
- package/docs/help/semantic.md +5 -4
- package/docs/ssss-spec.md +32 -77
- package/package.json +22 -3
- package/registry/core.json +21 -28
- package/scripts/build-reference-bundle.mjs +0 -20
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +6 -8
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +106 -186
- package/scripts/ssss.mjs +8 -2
- package/skills/ssss/SKILL.md +11 -15
- package/skills/ssss/evals/evals.json +3 -3
- package/skills/ssss/references/contract-reference.md +7 -8
- package/skills/ssss/scripts/validate-schema.mjs +1 -44
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +4 -4
- package/src/engine.mjs +101 -503
- package/src/events.mjs +90 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +125 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +62 -0
- package/src/registry.mjs +190 -39
- package/src/semantic.mjs +124 -316
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
- package/docs/help/localization.md +0 -43
- package/scripts/cmd-localize.mjs +0 -44
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSSS primitive-definition API (spec 0.9).
|
|
3
|
+
*
|
|
4
|
+
* Human-facing names and descriptions may be authored in any language. Stable
|
|
5
|
+
* primitive/field/action/capability identifiers are symbolic controls and are
|
|
6
|
+
* generated or validated here rather than translated by presentation layers.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { isPrimitiveTypeName } from './registry.mjs';
|
|
10
|
+
|
|
11
|
+
export const PRIMITIVE_MUTATIONS = ['replace', 'append'];
|
|
12
|
+
export const PRIMITIVE_PORTABILITY = ['structural', 'tenant_private', 'resource_bound'];
|
|
13
|
+
export const PRIMITIVE_SCOPES = ['system', 'account', 'workspace', 'user'];
|
|
14
|
+
export const FIELD_KINDS = [
|
|
15
|
+
'string', 'text', 'number', 'integer', 'boolean', 'datetime', 'date',
|
|
16
|
+
'enum', 'object', 'array', 'reference', 'secret_reference', 'resource_reference',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const NAMESPACE_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
20
|
+
const SYMBOL_RE = /^[a-z][a-z0-9_-]{0,127}$/;
|
|
21
|
+
const LANGUAGE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
22
|
+
const CAPABILITY_RE = /^[a-z][a-z0-9_.-]{0,63}:[a-z][a-z0-9_.-]{0,127}$/;
|
|
23
|
+
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function issue(field, message) {
|
|
29
|
+
return { field, issue: message };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function uniqueStrings(value) {
|
|
33
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string' && item) &&
|
|
34
|
+
new Set(value).size === value.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Generate a stable opaque local ID from an authored name in any language. */
|
|
38
|
+
export function generatePrimitiveId({ namespace, name, salt = '' }) {
|
|
39
|
+
if (!NAMESPACE_RE.test(namespace || '')) throw new Error('namespace must be a safe lowercase identifier.');
|
|
40
|
+
if (typeof name !== 'string' || !name.trim()) throw new Error('name must be non-empty.');
|
|
41
|
+
const digest = crypto.createHash('sha256')
|
|
42
|
+
.update(`${namespace}\0${name.normalize('NFKC')}\0${salt}`)
|
|
43
|
+
.digest('hex')
|
|
44
|
+
.slice(0, 20);
|
|
45
|
+
return `${namespace}:p_${digest}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validatePrimitiveDefinition(definition) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
if (!isRecord(definition)) return { valid: false, errors: [issue('(root)', 'Primitive definition must be an object.')] };
|
|
51
|
+
const required = ['primitive_id', 'namespace', 'version', 'name', 'mutation', 'portability', 'scopes', 'fields'];
|
|
52
|
+
for (const field of required) {
|
|
53
|
+
const value = definition[field];
|
|
54
|
+
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) {
|
|
55
|
+
errors.push(issue(field, `Missing required field '${field}'.`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!NAMESPACE_RE.test(definition.namespace || '')) {
|
|
59
|
+
errors.push(issue('namespace', 'Use a lowercase namespace containing letters, numbers, underscore, or hyphen.'));
|
|
60
|
+
}
|
|
61
|
+
if (!isPrimitiveTypeName(definition.primitive_id) ||
|
|
62
|
+
!definition.primitive_id?.startsWith(`${definition.namespace}:`)) {
|
|
63
|
+
errors.push(issue('primitive_id', 'Use a qualified identifier whose prefix matches namespace.'));
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isInteger(definition.version) || definition.version < 1) {
|
|
66
|
+
errors.push(issue('version', 'Version must be a positive integer.'));
|
|
67
|
+
}
|
|
68
|
+
if (definition.revision !== undefined && (!Number.isInteger(definition.revision) || definition.revision < 1)) {
|
|
69
|
+
errors.push(issue('revision', 'Revision must be a positive integer.'));
|
|
70
|
+
}
|
|
71
|
+
if (typeof definition.name !== 'string' || !definition.name.trim()) {
|
|
72
|
+
errors.push(issue('name', 'Name may use any language but must not be empty.'));
|
|
73
|
+
}
|
|
74
|
+
if (definition.language !== undefined && !LANGUAGE_RE.test(definition.language)) {
|
|
75
|
+
errors.push(issue('language', 'Use a BCP-47-style language tag.'));
|
|
76
|
+
}
|
|
77
|
+
if (!PRIMITIVE_MUTATIONS.includes(definition.mutation)) {
|
|
78
|
+
errors.push(issue('mutation', `Use one of: ${PRIMITIVE_MUTATIONS.join(', ')}.`));
|
|
79
|
+
}
|
|
80
|
+
if (!PRIMITIVE_PORTABILITY.includes(definition.portability)) {
|
|
81
|
+
errors.push(issue('portability', `Use one of: ${PRIMITIVE_PORTABILITY.join(', ')}.`));
|
|
82
|
+
}
|
|
83
|
+
if (!uniqueStrings(definition.scopes) ||
|
|
84
|
+
definition.scopes.some((scope) => !PRIMITIVE_SCOPES.includes(scope))) {
|
|
85
|
+
errors.push(issue('scopes', `Provide a unique non-empty subset of: ${PRIMITIVE_SCOPES.join(', ')}.`));
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(definition.fields)) {
|
|
88
|
+
errors.push(issue('fields', 'Fields must be an array.'));
|
|
89
|
+
} else {
|
|
90
|
+
const ids = new Set();
|
|
91
|
+
for (const [index, field] of definition.fields.entries()) {
|
|
92
|
+
const prefix = `fields[${index}]`;
|
|
93
|
+
if (!isRecord(field)) {
|
|
94
|
+
errors.push(issue(prefix, 'Field definition must be an object.'));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!SYMBOL_RE.test(field.id || '')) errors.push(issue(`${prefix}.id`, 'Use a stable lowercase symbolic field ID.'));
|
|
98
|
+
else if (ids.has(field.id)) errors.push(issue(`${prefix}.id`, `Duplicate field ID '${field.id}'.`));
|
|
99
|
+
else ids.add(field.id);
|
|
100
|
+
if (typeof field.name !== 'string' || !field.name.trim()) {
|
|
101
|
+
errors.push(issue(`${prefix}.name`, 'Field name may use any language but must not be empty.'));
|
|
102
|
+
}
|
|
103
|
+
if (!FIELD_KINDS.includes(field.kind)) {
|
|
104
|
+
errors.push(issue(`${prefix}.kind`, `Use one of: ${FIELD_KINDS.join(', ')}.`));
|
|
105
|
+
}
|
|
106
|
+
if (field.kind === 'enum' && !uniqueStrings(field.values)) {
|
|
107
|
+
errors.push(issue(`${prefix}.values`, 'Enum fields require unique non-empty symbolic values.'));
|
|
108
|
+
}
|
|
109
|
+
if (field.pattern !== undefined) {
|
|
110
|
+
try { new RegExp(field.pattern, 'u'); }
|
|
111
|
+
catch (error) { errors.push(issue(`${prefix}.pattern`, `Invalid regular expression: ${error.message}`)); }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (definition.capabilities !== undefined) {
|
|
116
|
+
if (!isRecord(definition.capabilities)) errors.push(issue('capabilities', 'Capabilities must be an action-to-list object.'));
|
|
117
|
+
else for (const [action, values] of Object.entries(definition.capabilities)) {
|
|
118
|
+
if (!SYMBOL_RE.test(action) || !uniqueStrings(values) || values.some((value) => !CAPABILITY_RE.test(value))) {
|
|
119
|
+
errors.push(issue(`capabilities.${action}`, 'Use unique qualified capability IDs.'));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (definition.projections !== undefined && !Array.isArray(definition.projections)) {
|
|
124
|
+
errors.push(issue('projections', 'Projections must be an array.'));
|
|
125
|
+
}
|
|
126
|
+
if (definition.aliases !== undefined && (!uniqueStrings(definition.aliases) ||
|
|
127
|
+
definition.aliases.some((alias) => !isPrimitiveTypeName(alias)))) {
|
|
128
|
+
errors.push(issue('aliases', 'Aliases must be unique safe primitive identifiers.'));
|
|
129
|
+
}
|
|
130
|
+
if (definition.migrations !== undefined && !Array.isArray(definition.migrations)) {
|
|
131
|
+
errors.push(issue('migrations', 'Migrations must be an array.'));
|
|
132
|
+
}
|
|
133
|
+
return { valid: errors.length === 0, errors };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize a user/repo-authored definition and generate missing symbolic IDs.
|
|
138
|
+
*/
|
|
139
|
+
export function definePrimitive(input, options = {}) {
|
|
140
|
+
if (!isRecord(input)) throw new Error('Primitive input must be an object.');
|
|
141
|
+
const namespace = input.namespace || options.namespace;
|
|
142
|
+
const primitive_id = input.primitive_id || generatePrimitiveId({ namespace, name: input.name, salt: options.salt });
|
|
143
|
+
const fields = (input.fields || []).map((field, index) => ({
|
|
144
|
+
...field,
|
|
145
|
+
id: field.id || `field_${crypto.createHash('sha256')
|
|
146
|
+
.update(`${primitive_id}\0${field.name || index}`)
|
|
147
|
+
.digest('hex').slice(0, 12)}`,
|
|
148
|
+
}));
|
|
149
|
+
const definition = {
|
|
150
|
+
type: 'ssss:primitive',
|
|
151
|
+
version: 1,
|
|
152
|
+
revision: 1,
|
|
153
|
+
mutation: 'replace',
|
|
154
|
+
portability: 'structural',
|
|
155
|
+
scopes: ['workspace'],
|
|
156
|
+
...input,
|
|
157
|
+
namespace,
|
|
158
|
+
primitive_id,
|
|
159
|
+
fields,
|
|
160
|
+
};
|
|
161
|
+
const result = validatePrimitiveDefinition(definition);
|
|
162
|
+
if (!result.valid) {
|
|
163
|
+
const error = new Error(result.errors.map((entry) => `${entry.field}: ${entry.issue}`).join('; '));
|
|
164
|
+
error.field_errors = result.errors;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
return definition;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Convert a rich primitive definition into the executable registry schema. */
|
|
171
|
+
export function primitiveDefinitionToRegistryEntry(definition) {
|
|
172
|
+
const result = validatePrimitiveDefinition(definition);
|
|
173
|
+
if (!result.valid) throw new Error(result.errors.map((entry) => `${entry.field}: ${entry.issue}`).join('; '));
|
|
174
|
+
const required_fields = ['type'];
|
|
175
|
+
const optional_fields = [];
|
|
176
|
+
const enums = {};
|
|
177
|
+
const patterns = {};
|
|
178
|
+
const references = {};
|
|
179
|
+
for (const field of definition.fields) {
|
|
180
|
+
(field.required ? required_fields : optional_fields).push(field.id);
|
|
181
|
+
if (field.kind === 'enum') enums[field.id] = field.values;
|
|
182
|
+
if (field.pattern) patterns[field.id] = field.pattern;
|
|
183
|
+
if (field.kind === 'reference') references[field.id] = {
|
|
184
|
+
must_exist: field.must_exist !== false,
|
|
185
|
+
...(field.allowed_types ? { allowed_types: field.allowed_types } : {}),
|
|
186
|
+
...(field.allowed_portability ? { allowed_portability: field.allowed_portability } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
family: definition.family || 'extension',
|
|
191
|
+
canonical_path: definition.path_template || null,
|
|
192
|
+
append_only: definition.mutation === 'append',
|
|
193
|
+
portability: definition.portability,
|
|
194
|
+
required_fields,
|
|
195
|
+
optional_fields,
|
|
196
|
+
...(Object.keys(enums).length ? { enums } : {}),
|
|
197
|
+
...(Object.keys(patterns).length ? { patterns } : {}),
|
|
198
|
+
...(Object.keys(references).length ? { references } : {}),
|
|
199
|
+
scopes: definition.scopes,
|
|
200
|
+
capabilities: definition.capabilities || {},
|
|
201
|
+
events: definition.events || [],
|
|
202
|
+
projections: definition.projections || [],
|
|
203
|
+
ui: definition.ui || {},
|
|
204
|
+
aliases: definition.aliases || [],
|
|
205
|
+
migrations: definition.migrations || [],
|
|
206
|
+
primitive_version: definition.version,
|
|
207
|
+
primitive_revision: definition.revision || 1,
|
|
208
|
+
authored_language: definition.language || null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Package one or more definitions as an extension registry object. */
|
|
213
|
+
export function definitionsToExtensionRegistry(namespace, definitions, options = {}) {
|
|
214
|
+
if (!NAMESPACE_RE.test(namespace || '')) throw new Error('Invalid namespace.');
|
|
215
|
+
const document_primitives = {};
|
|
216
|
+
for (const definition of definitions) {
|
|
217
|
+
if (definition.namespace !== namespace) throw new Error(`Definition '${definition.primitive_id}' belongs to another namespace.`);
|
|
218
|
+
document_primitives[definition.primitive_id] = primitiveDefinitionToRegistryEntry(definition);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
registry: namespace,
|
|
222
|
+
extends: 'ssss',
|
|
223
|
+
version: options.version || '1.0.0',
|
|
224
|
+
requires: options.requires || {},
|
|
225
|
+
document_primitives,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Replayable, cursor-aware SSSS projection coordinator. */
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function stable(value) {
|
|
5
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
6
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function projectionHash(value) {
|
|
11
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ProjectionCoordinator {
|
|
15
|
+
constructor({ eventStore, cursorStore = new Map() } = {}) {
|
|
16
|
+
if (!eventStore) throw new Error('ProjectionCoordinator requires an eventStore.');
|
|
17
|
+
this.eventStore = eventStore;
|
|
18
|
+
this.cursorStore = cursorStore;
|
|
19
|
+
this.projections = new Map();
|
|
20
|
+
}
|
|
21
|
+
register(definition) {
|
|
22
|
+
if (!definition?.id || typeof definition.apply !== 'function') throw new Error('Projection id and apply(event, context) are required.');
|
|
23
|
+
if (this.projections.has(definition.id)) throw new Error(`Duplicate projection '${definition.id}'.`);
|
|
24
|
+
this.projections.set(definition.id, definition);
|
|
25
|
+
}
|
|
26
|
+
async replay(id, options = {}) {
|
|
27
|
+
const projection = this.projections.get(id);
|
|
28
|
+
if (!projection) throw new Error(`Unknown projection '${id}'.`);
|
|
29
|
+
let cursor = options.rebuild ? 0 : (this.cursorStore.get(id) || 0);
|
|
30
|
+
if (options.rebuild && projection.reset) await projection.reset();
|
|
31
|
+
let applied = 0;
|
|
32
|
+
for await (const item of this.eventStore.replay({ cursor, workspaceId: options.workspaceId })) {
|
|
33
|
+
if (!projection.filter || projection.filter(item.event)) {
|
|
34
|
+
await projection.apply(item.event, { replay: true, cursor: item.cursor });
|
|
35
|
+
applied++;
|
|
36
|
+
}
|
|
37
|
+
cursor = item.cursor;
|
|
38
|
+
this.cursorStore.set(id, cursor);
|
|
39
|
+
}
|
|
40
|
+
const state = projection.snapshot ? await projection.snapshot() : null;
|
|
41
|
+
return { id, cursor, applied, output_hash: state === null ? null : projectionHash(state) };
|
|
42
|
+
}
|
|
43
|
+
async dispatch(event) {
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const projection of this.projections.values()) {
|
|
46
|
+
if (projection.filter && !projection.filter(event)) continue;
|
|
47
|
+
try {
|
|
48
|
+
await projection.apply(event, { replay: false });
|
|
49
|
+
results.push({ id: projection.id, success: true });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
results.push({ id: projection.id, success: false, error: error.message });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
async detectDrift(id, expectedHash) {
|
|
57
|
+
const projection = this.projections.get(id);
|
|
58
|
+
if (!projection?.snapshot) throw new Error(`Projection '${id}' does not expose snapshot().`);
|
|
59
|
+
const actualHash = projectionHash(await projection.snapshot());
|
|
60
|
+
return { drift: actualHash !== expectedHash, expected_hash: expectedHash, actual_hash: actualHash };
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/registry.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
|
|
|
18
18
|
|
|
19
19
|
const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
20
20
|
const SAFE_REGISTRY_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
21
|
-
const SAFE_TYPE_NAME = /^[a-z][a-z0-9_-]{0,127}$/;
|
|
21
|
+
const SAFE_TYPE_NAME = /^(?:[a-z][a-z0-9_-]{0,63}:)?[a-z][a-z0-9_-]{0,127}$/;
|
|
22
22
|
const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
23
23
|
|
|
24
24
|
function isRecord(value) {
|
|
@@ -109,58 +109,124 @@ function validatePrimitiveDefinition(name, def, source, portabilityClasses) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** Return true when a primitive identifier is a safe local or qualified name. */
|
|
113
|
+
export function isPrimitiveTypeName(value) {
|
|
114
|
+
return typeof value === 'string' && SAFE_TYPE_NAME.test(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeExtensionInput(item, index) {
|
|
118
|
+
if (item?.document_primitives) return { source: item.source || `extension[${index}]`, value: item };
|
|
119
|
+
if (item?.registry?.document_primitives) {
|
|
120
|
+
return { source: item.source || `extension[${index}]`, value: item.registry };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`extension[${index}] must be an extension registry object.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function semverSatisfies(version, range) {
|
|
126
|
+
if (range === '*' || range === undefined) return true;
|
|
127
|
+
if (version === range) return true;
|
|
128
|
+
const current = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
129
|
+
const wanted = String(range).match(/^([~^])(\d+)\.(\d+)\.(\d+)$/);
|
|
130
|
+
if (!current || !wanted) return false;
|
|
131
|
+
const [, operator, major, minor, patch] = wanted;
|
|
132
|
+
const [cm, cn, cp] = current.slice(1).map(Number);
|
|
133
|
+
const [wm, wn, wp] = [major, minor, patch].map(Number);
|
|
134
|
+
if (operator === '^') return cm === wm && (cn > wn || (cn === wn && cp >= wp));
|
|
135
|
+
return cm === wm && cn === wn && cp >= wp;
|
|
136
|
+
}
|
|
137
|
+
|
|
112
138
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
139
|
+
* Compose the package core with extension/repository/workspace registry objects.
|
|
140
|
+
* This is the runtime composition path; hosts do not copy core.json into their repos.
|
|
116
141
|
*/
|
|
117
|
-
export function
|
|
118
|
-
|
|
119
|
-
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
142
|
+
export function composeRegistries({ core, extensions: extensionInputs = [] } = {}) {
|
|
143
|
+
if (!isRecord(core)) throw new Error('core registry must be an object.');
|
|
120
144
|
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
121
|
-
if (!portabilityClasses.length) throw new Error('core
|
|
122
|
-
const extensions = new Set();
|
|
145
|
+
if (!portabilityClasses.length) throw new Error('core registry: portability.classes must not be empty.');
|
|
123
146
|
|
|
147
|
+
const extensions = new Set();
|
|
148
|
+
const extensionVersions = new Map();
|
|
149
|
+
const extensionRequirements = [];
|
|
124
150
|
const types = new Map();
|
|
151
|
+
const aliases = new Map();
|
|
152
|
+
|
|
125
153
|
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
126
|
-
validatePrimitiveDefinition(name, def, 'core
|
|
127
|
-
|
|
154
|
+
validatePrimitiveDefinition(name, def, 'core registry', portabilityClasses);
|
|
155
|
+
const value = { ...def, type: name, qualified_type: `ssss:${name}`, registry: 'core' };
|
|
156
|
+
types.set(name, value);
|
|
157
|
+
aliases.set(`ssss:${name}`, name);
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
160
|
+
for (const [index, item] of extensionInputs.entries()) {
|
|
161
|
+
const { source, value: ext } = normalizeExtensionInput(item, index);
|
|
162
|
+
if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
|
|
163
|
+
throw new Error(`${source}: registry must be a safe identifier.`);
|
|
164
|
+
}
|
|
165
|
+
if (ext.registry === 'core' || ext.registry === 'ssss' || extensions.has(ext.registry)) {
|
|
166
|
+
throw new Error(`${source}: duplicate or reserved registry name '${ext.registry}'.`);
|
|
167
|
+
}
|
|
168
|
+
if (ext.extends !== undefined && ext.extends !== 'core' && ext.extends !== 'ssss') {
|
|
169
|
+
throw new Error(`${source}: extends must be 'core' or 'ssss'.`);
|
|
170
|
+
}
|
|
171
|
+
if (ext.version !== undefined &&
|
|
172
|
+
(typeof ext.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ext.version))) {
|
|
173
|
+
throw new Error(`${source}: version must be a semantic version string.`);
|
|
174
|
+
}
|
|
175
|
+
assertRecord(ext.requires, `${source}: requires`);
|
|
176
|
+
extensions.add(ext.registry);
|
|
177
|
+
extensionVersions.set(ext.registry, ext.version || '0.0.0');
|
|
178
|
+
extensionRequirements.push({ source, registry: ext.registry, requires: ext.requires || {} });
|
|
179
|
+
|
|
180
|
+
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
181
|
+
validatePrimitiveDefinition(name, def, source, portabilityClasses);
|
|
182
|
+
const qualified = name.includes(':') ? name : `${ext.registry}:${name}`;
|
|
183
|
+
if (name.includes(':') && !name.startsWith(`${ext.registry}:`)) {
|
|
184
|
+
throw new Error(`${source}: primitive '${name}' must use registry namespace '${ext.registry}'.`);
|
|
140
185
|
}
|
|
141
|
-
if (
|
|
142
|
-
|
|
186
|
+
if (types.has(name) || aliases.has(name) || types.has(qualified) || aliases.has(qualified)) {
|
|
187
|
+
const existing = types.get(name) || types.get(aliases.get(name)) ||
|
|
188
|
+
types.get(qualified) || types.get(aliases.get(qualified));
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${source}: primitive '${name}' collides with registry '${existing?.registry || 'unknown'}'.`
|
|
191
|
+
);
|
|
143
192
|
}
|
|
144
|
-
|
|
145
|
-
|
|
193
|
+
const value = { ...def, type: name, qualified_type: qualified, registry: ext.registry };
|
|
194
|
+
types.set(name, value);
|
|
195
|
+
if (qualified !== name) aliases.set(qualified, name);
|
|
196
|
+
for (const alias of def.aliases || []) {
|
|
197
|
+
if (!isPrimitiveTypeName(alias)) throw new Error(`${source}: primitive '${name}' has unsafe alias '${alias}'.`);
|
|
198
|
+
if (types.has(alias) || aliases.has(alias)) throw new Error(`${source}: primitive alias '${alias}' collides with an existing primitive.`);
|
|
199
|
+
aliases.set(alias, name);
|
|
146
200
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
types.set(name, { ...def, type: name, registry: ext.registry });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const requirement of extensionRequirements) {
|
|
205
|
+
for (const [dependency, range] of Object.entries(requirement.requires)) {
|
|
206
|
+
if (!extensionVersions.has(dependency)) throw new Error(`${requirement.source}: missing required extension '${dependency}'.`);
|
|
207
|
+
if (typeof range !== 'string' || !semverSatisfies(extensionVersions.get(dependency), range)) {
|
|
208
|
+
throw new Error(`${requirement.source}: extension '${dependency}' version '${extensionVersions.get(dependency)}' does not satisfy '${range}'.`);
|
|
156
209
|
}
|
|
157
210
|
}
|
|
158
211
|
}
|
|
159
212
|
|
|
213
|
+
const registrySet = {
|
|
214
|
+
types,
|
|
215
|
+
aliases,
|
|
216
|
+
contractTypes: new Set(Object.keys(core.contract_primitives || {})),
|
|
217
|
+
portabilityClasses,
|
|
218
|
+
core,
|
|
219
|
+
extensions,
|
|
220
|
+
extensionVersions,
|
|
221
|
+
};
|
|
222
|
+
// Backward-compatible Map surface used by the existing engine/bundle APIs.
|
|
223
|
+
// The alias table lets shared validators resolve qualified 0.9 identifiers.
|
|
224
|
+
types.aliases = aliases;
|
|
225
|
+
|
|
160
226
|
for (const def of types.values()) {
|
|
161
227
|
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
162
228
|
for (const target of [...(constraint.allowed_types || []), ...(constraint.disallowed_types || [])]) {
|
|
163
|
-
if (!
|
|
229
|
+
if (!isPrimitiveTypeName(target) || !resolvePrimitiveDefinition(registrySet, target)) {
|
|
164
230
|
throw new Error(
|
|
165
231
|
`registry '${def.registry}' primitive '${def.type}' reference '${field}' names unknown type '${target}'.`
|
|
166
232
|
);
|
|
@@ -169,8 +235,91 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
|
169
235
|
}
|
|
170
236
|
}
|
|
171
237
|
|
|
172
|
-
|
|
173
|
-
|
|
238
|
+
return registrySet;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Compose named ownership layers without giving lower scopes override semantics. */
|
|
242
|
+
export function composeRegistryLayers({ core, installed = [], repository = [], workspace = [], user = [], policyFloors = {} } = {}) {
|
|
243
|
+
const extensions = [
|
|
244
|
+
...installed.map((registry) => ({ source: 'installed', registry })),
|
|
245
|
+
...repository.map((registry) => ({ source: 'repository', registry })),
|
|
246
|
+
...workspace.map((registry) => ({ source: 'workspace', registry })),
|
|
247
|
+
...user.map((registry) => ({ source: 'user', registry })),
|
|
248
|
+
];
|
|
249
|
+
const registrySet = composeRegistries({ core, extensions });
|
|
250
|
+
for (const [type, actions] of Object.entries(policyFloors)) {
|
|
251
|
+
const definition = resolvePrimitiveDefinition(registrySet, type);
|
|
252
|
+
if (!definition) throw new Error(`Policy floor names unknown primitive '${type}'.`);
|
|
253
|
+
for (const [action, required] of Object.entries(actions || {})) {
|
|
254
|
+
assertStringArray(required, `policy floor '${type}.${action}'`);
|
|
255
|
+
const declared = new Set(definition.capabilities?.[action] || []);
|
|
256
|
+
const missing = required.filter((capability) => !declared.has(capability));
|
|
257
|
+
if (missing.length) throw new Error(`Primitive '${type}' weakens policy floor for '${action}': ${missing.join(', ')}.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return registrySet;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function stableObject(value) {
|
|
264
|
+
if (Array.isArray(value)) return value.map(stableObject);
|
|
265
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableObject(value[key])]));
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createRegistryLock(registrySet) {
|
|
270
|
+
const primitives = [...registrySet.types.values()]
|
|
271
|
+
.map((definition) => ({ ...definition }))
|
|
272
|
+
.sort((a, b) => a.qualified_type.localeCompare(b.qualified_type));
|
|
273
|
+
const payload = {
|
|
274
|
+
version: 1,
|
|
275
|
+
core_spec_version: registrySet.core.spec_version,
|
|
276
|
+
extensions: Object.fromEntries([...registrySet.extensionVersions.entries()].sort()),
|
|
277
|
+
primitives,
|
|
278
|
+
};
|
|
279
|
+
return { ...payload, integrity: `sha256:${crypto.createHash('sha256').update(JSON.stringify(stableObject(payload))).digest('hex')}` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function verifyRegistryLock(registrySet, lock) {
|
|
283
|
+
const expected = createRegistryLock(registrySet);
|
|
284
|
+
return {
|
|
285
|
+
valid: !!lock && lock.integrity === expected.integrity,
|
|
286
|
+
expected_integrity: expected.integrity,
|
|
287
|
+
actual_integrity: lock?.integrity || null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve local, qualified, or explicit migration-alias primitive identifiers. */
|
|
292
|
+
export function resolvePrimitiveDefinition(registrySet, type) {
|
|
293
|
+
if (!registrySet || typeof type !== 'string') return null;
|
|
294
|
+
const direct = registrySet.types?.get(type);
|
|
295
|
+
if (direct) return direct;
|
|
296
|
+
const local = registrySet.aliases?.get(type);
|
|
297
|
+
return local ? registrySet.types.get(local) || null : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Load the core registry and merge extension registries.
|
|
302
|
+
* @param {string} [registryDir] - directory containing core.json + extensions/
|
|
303
|
+
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
304
|
+
*/
|
|
305
|
+
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
306
|
+
const corePath = path.join(registryDir, 'core.json');
|
|
307
|
+
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
308
|
+
const extensionInputs = [];
|
|
309
|
+
const extDir = path.join(registryDir, 'extensions');
|
|
310
|
+
if (fs.existsSync(extDir)) {
|
|
311
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
312
|
+
const extPath = path.join(extDir, file);
|
|
313
|
+
if (fs.lstatSync(extPath).isSymbolicLink()) {
|
|
314
|
+
throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
|
|
315
|
+
}
|
|
316
|
+
extensionInputs.push({
|
|
317
|
+
source: `extensions/${file}`,
|
|
318
|
+
registry: JSON.parse(fs.readFileSync(extPath, 'utf8')),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return composeRegistries({ core, extensions: extensionInputs });
|
|
174
323
|
}
|
|
175
324
|
|
|
176
325
|
/** Is this document type append-only (events appended, never rewritten)? */
|
|
@@ -258,6 +407,8 @@ export function validateDataConstraints(typeDef, data, universalRequired = []) {
|
|
|
258
407
|
*/
|
|
259
408
|
export function validateReferenceConstraints(typeDef, data, resolveReference, types) {
|
|
260
409
|
const issues = [];
|
|
410
|
+
const lookupType = (name) => types.get(name) ||
|
|
411
|
+
(types.aliases?.get(name) ? types.get(types.aliases.get(name)) : null);
|
|
261
412
|
for (const [field, constraint] of Object.entries(typeDef?.references || {})) {
|
|
262
413
|
const referencePath = data[field];
|
|
263
414
|
if (referencePath === undefined || referencePath === null || referencePath === '') continue;
|
|
@@ -278,7 +429,7 @@ export function validateReferenceConstraints(typeDef, data, resolveReference, ty
|
|
|
278
429
|
continue;
|
|
279
430
|
}
|
|
280
431
|
const referencedType = referenced.data?.type;
|
|
281
|
-
if (!
|
|
432
|
+
if (!lookupType(referencedType)) {
|
|
282
433
|
issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
|
|
283
434
|
continue;
|
|
284
435
|
}
|
|
@@ -288,7 +439,7 @@ export function validateReferenceConstraints(typeDef, data, resolveReference, ty
|
|
|
288
439
|
if (constraint.disallowed_types?.includes(referencedType)) {
|
|
289
440
|
issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
|
|
290
441
|
}
|
|
291
|
-
const referencedPortability = resolvePortability(
|
|
442
|
+
const referencedPortability = resolvePortability(lookupType(referencedType), referenced.data);
|
|
292
443
|
if (constraint.allowed_portability && !constraint.allowed_portability.includes(referencedPortability)) {
|
|
293
444
|
issues.push({
|
|
294
445
|
field,
|