@gregiteen/ssss-cli 0.13.1 → 0.14.1
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/CHANGELOG.md +38 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +121 -16
- package/package.json +1 -1
- package/registry/core.json +14 -0
- package/scripts/audit-registry-field-usage.mjs +3 -0
- package/scripts/cmd-adapter.mjs +2 -0
- package/scripts/cmd-bridge.mjs +113 -0
- package/scripts/cmd-import.mjs +21 -1
- package/scripts/cmd-provision.mjs +33 -2
- package/scripts/cmd-serve.mjs +155 -0
- package/scripts/cmd-upgrade.mjs +110 -0
- package/scripts/conformance-09.mjs +139 -0
- package/scripts/conformance.mjs +473 -1
- package/scripts/ssss.mjs +7 -1
- package/skills/ssss/SKILL.md +2 -2
- package/skills/ssss/core/SKILL.md +3 -3
- package/skills/ssss/core/references/core-registry.json +14 -0
- package/skills/ssss/core/references/ssss-spec.md +121 -16
- package/src/authorization.mjs +58 -7
- package/src/bundle.mjs +469 -27
- package/src/engine.mjs +11 -1
- package/src/kernel.mjs +30 -5
- package/src/projections.mjs +93 -0
- package/src/registry.mjs +82 -3
package/src/bundle.mjs
CHANGED
|
@@ -284,6 +284,22 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
284
284
|
for (const key of ['primitives', 'extensions', 'migrations']) {
|
|
285
285
|
if (!Array.isArray(m.dependencies[key])) errors.push(`manifest.dependencies.${key} must be an array`);
|
|
286
286
|
}
|
|
287
|
+
if (m.dependencies.bundles !== undefined) {
|
|
288
|
+
if (!Array.isArray(m.dependencies.bundles)) {
|
|
289
|
+
errors.push('manifest.dependencies.bundles must be an array');
|
|
290
|
+
} else {
|
|
291
|
+
for (const b of m.dependencies.bundles) {
|
|
292
|
+
if (!b || typeof b !== 'object') {
|
|
293
|
+
errors.push('manifest.dependencies.bundles entries must be objects');
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!b.id && !b.name) errors.push("manifest.dependencies.bundles entry missing 'id' or 'name'");
|
|
297
|
+
if (b.installMode && !['optional', 'recommended', 'required'].includes(b.installMode)) {
|
|
298
|
+
errors.push(`invalid installMode '${b.installMode}' in bundle dependency`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
287
303
|
if (!m.dependencies.integrity || typeof m.dependencies.integrity !== 'object' || Array.isArray(m.dependencies.integrity)) {
|
|
288
304
|
errors.push('manifest.dependencies.integrity must be an object');
|
|
289
305
|
} else if (typeof m.dependencies.integrity.content_hash !== 'string' || !m.dependencies.integrity.content_hash.startsWith('sha256:')) {
|
|
@@ -436,52 +452,308 @@ function internalRefs(content) {
|
|
|
436
452
|
* list of Operation Contract envelopes (§6). Does not touch the filesystem.
|
|
437
453
|
*/
|
|
438
454
|
export function provisionBundle(bundle, opts = {}) {
|
|
439
|
-
const {
|
|
440
|
-
|
|
455
|
+
const {
|
|
456
|
+
parameters = {},
|
|
457
|
+
workspaceId = 'ws-provision',
|
|
458
|
+
pathPrefix = '',
|
|
459
|
+
dryRun = false,
|
|
460
|
+
expectedHashes = {},
|
|
461
|
+
dependencies = {},
|
|
462
|
+
registryDir,
|
|
463
|
+
} = opts;
|
|
464
|
+
const validation = validateBundle(bundle, { registryDir });
|
|
441
465
|
if (!validation.valid) {
|
|
442
|
-
return { ok: false, plan: [], unresolved: [], danglingLinks: [], errors: validation.errors, steps: [] };
|
|
466
|
+
return { ok: false, plan: [], unresolved: [], danglingLinks: [], bindings: [], dependencies: { installed: [], suggested: [] }, errors: validation.errors, steps: [] };
|
|
443
467
|
}
|
|
444
468
|
const m = bundle.manifest;
|
|
445
|
-
|
|
446
|
-
// Resolve required parameters (§16.5).
|
|
469
|
+
const errors = [];
|
|
447
470
|
const unresolved = [];
|
|
471
|
+
|
|
472
|
+
// 1. Resolve and type-check parameters (§16.5).
|
|
473
|
+
const paramMap = new Map();
|
|
474
|
+
const paramDefMap = new Map();
|
|
448
475
|
for (const p of m.parameters || []) {
|
|
449
|
-
|
|
476
|
+
paramDefMap.set(p.key, p);
|
|
477
|
+
const val = parameters[p.key] !== undefined ? parameters[p.key] : p.defaultValue;
|
|
478
|
+
if (val === undefined) {
|
|
479
|
+
if (p.required) unresolved.push(p.key);
|
|
480
|
+
} else {
|
|
481
|
+
if (p.type === 'string' && typeof val !== 'string') errors.push(`parameter '${p.key}' must be a string`);
|
|
482
|
+
else if (p.type === 'boolean' && typeof val !== 'boolean') errors.push(`parameter '${p.key}' must be a boolean`);
|
|
483
|
+
else if (p.type === 'number' && typeof val !== 'number') errors.push(`parameter '${p.key}' must be a number`);
|
|
484
|
+
paramMap.set(p.key, val);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 2. Dependencies (§17.4)
|
|
489
|
+
const resolvedDependencies = {
|
|
490
|
+
installed: [],
|
|
491
|
+
suggested: [],
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const declaredBundles = Array.isArray(m.dependencies?.bundles)
|
|
495
|
+
? m.dependencies.bundles
|
|
496
|
+
: (Array.isArray(m.bundle_dependencies) ? m.bundle_dependencies : []);
|
|
497
|
+
|
|
498
|
+
const depPool = new Map();
|
|
499
|
+
if (bundle) {
|
|
500
|
+
if (bundle.manifest?.name) depPool.set(bundle.manifest.name, bundle);
|
|
501
|
+
if (bundle.manifest?.id) depPool.set(bundle.manifest.id, bundle);
|
|
502
|
+
}
|
|
503
|
+
if (dependencies && typeof dependencies === 'object') {
|
|
504
|
+
const entries = dependencies instanceof Map
|
|
505
|
+
? dependencies.entries()
|
|
506
|
+
: Object.entries(dependencies);
|
|
507
|
+
for (const [key, depBundle] of entries) {
|
|
508
|
+
if (depBundle) {
|
|
509
|
+
depPool.set(key, depBundle);
|
|
510
|
+
if (depBundle.manifest?.name) depPool.set(depBundle.manifest.name, depBundle);
|
|
511
|
+
if (depBundle.manifest?.id) depPool.set(depBundle.manifest.id, depBundle);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
450
514
|
}
|
|
451
515
|
|
|
452
|
-
|
|
453
|
-
|
|
516
|
+
const visiting = new Set();
|
|
517
|
+
const visited = new Set();
|
|
518
|
+
const orderedDeps = [];
|
|
519
|
+
|
|
520
|
+
function resolveDeps(b, pathStack = []) {
|
|
521
|
+
const bId = b.manifest?.name || b.manifest?.id || '(bundle)';
|
|
522
|
+
if (visiting.has(bId)) {
|
|
523
|
+
const cycle = [...pathStack, bId].join(' -> ');
|
|
524
|
+
throw new Error(`dependency cycle detected: ${cycle}`);
|
|
525
|
+
}
|
|
526
|
+
if (visited.has(bId)) return;
|
|
527
|
+
visiting.add(bId);
|
|
528
|
+
|
|
529
|
+
const bDeclared = Array.isArray(b.manifest?.dependencies?.bundles)
|
|
530
|
+
? b.manifest.dependencies.bundles
|
|
531
|
+
: (Array.isArray(b.manifest?.bundle_dependencies) ? b.manifest.bundle_dependencies : []);
|
|
532
|
+
|
|
533
|
+
for (const dep of bDeclared) {
|
|
534
|
+
const mode = dep.installMode || dep.install_mode || 'required';
|
|
535
|
+
const depKey = dep.id || dep.name;
|
|
536
|
+
if (mode === 'required') {
|
|
537
|
+
const depBundle = depPool.get(depKey);
|
|
538
|
+
if (!depBundle) {
|
|
539
|
+
errors.push(`missing required bundle dependency '${depKey}'`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
resolveDeps(depBundle, [...pathStack, bId]);
|
|
543
|
+
} else {
|
|
544
|
+
if (!resolvedDependencies.suggested.some((s) => s.id === depKey)) {
|
|
545
|
+
resolvedDependencies.suggested.push({
|
|
546
|
+
id: depKey,
|
|
547
|
+
name: dep.name || depKey,
|
|
548
|
+
installMode: mode,
|
|
549
|
+
description: dep.description || '',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
visiting.delete(bId);
|
|
556
|
+
visited.add(bId);
|
|
557
|
+
if (b !== bundle) {
|
|
558
|
+
orderedDeps.push({ id: bId, bundle: b });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
resolveDeps(bundle);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
errors.push(err.message);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const dependencyPlans = [];
|
|
569
|
+
const dependencyBindings = [];
|
|
570
|
+
const dependencySteps = [];
|
|
571
|
+
|
|
572
|
+
for (const { id: depId, bundle: depBundle } of orderedDeps) {
|
|
573
|
+
const depResult = provisionBundle(depBundle, {
|
|
574
|
+
...opts,
|
|
575
|
+
dependencies: {},
|
|
576
|
+
});
|
|
577
|
+
if (!depResult.ok) {
|
|
578
|
+
errors.push(...depResult.errors);
|
|
579
|
+
unresolved.push(...depResult.unresolved);
|
|
580
|
+
}
|
|
581
|
+
dependencyPlans.push(...depResult.plan);
|
|
582
|
+
dependencyBindings.push(...depResult.bindings);
|
|
583
|
+
dependencySteps.push(...depResult.steps);
|
|
584
|
+
resolvedDependencies.installed.push(depId);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 3. Setup path remapping and slug dictionary (§17.3)
|
|
454
588
|
const normalizedPrefix = pathPrefix ? pathPrefix.replace(/\/$/, '') : '';
|
|
455
589
|
if (normalizedPrefix && !isSafeBundlePath(normalizedPrefix)) {
|
|
456
|
-
return { ok: false, plan: [], unresolved, danglingLinks: [], errors: [`unsafe path prefix '${pathPrefix}'`], steps: m.provisioning || [] };
|
|
590
|
+
return { ok: false, plan: [], unresolved, danglingLinks: [], bindings: [], dependencies: resolvedDependencies, errors: [`unsafe path prefix '${pathPrefix}'`], steps: m.provisioning || [] };
|
|
457
591
|
}
|
|
458
|
-
const
|
|
592
|
+
const remapPath = (rel) => (normalizedPrefix ? `${normalizedPrefix}/${rel}` : rel);
|
|
593
|
+
const slugPrefix = normalizedPrefix ? normalizedPrefix.replace(/[/_]/g, '-') + '-' : '';
|
|
594
|
+
|
|
595
|
+
const declaredExts = m.required_extensions;
|
|
596
|
+
const registryNames = Array.isArray(declaredExts) && declaredExts.length ? declaredExts : null;
|
|
597
|
+
const registrySet = loadRegistries(registryDir, registryNames ? { only: registryNames } : {});
|
|
598
|
+
|
|
599
|
+
const idMap = new Map();
|
|
600
|
+
const slugMap = new Map();
|
|
459
601
|
const presentSlugs = new Set();
|
|
602
|
+
const fileParsedList = [];
|
|
603
|
+
|
|
460
604
|
for (const f of bundle.files) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
605
|
+
const parsed = parseDocument(f.content);
|
|
606
|
+
fileParsedList.push({ file: f, parsed });
|
|
607
|
+
const targetPath = remapPath(f.path);
|
|
608
|
+
if (parsed.data.slug) {
|
|
609
|
+
const targetSlug = normalizedPrefix
|
|
610
|
+
? `${slugPrefix}${crypto.createHash('sha256').update(workspaceId + ':' + parsed.data.slug).digest('hex').slice(0, 12)}`
|
|
611
|
+
: parsed.data.slug;
|
|
612
|
+
slugMap.set(parsed.data.slug, targetSlug);
|
|
613
|
+
presentSlugs.add(targetSlug);
|
|
614
|
+
}
|
|
615
|
+
if (parsed.data.id) {
|
|
616
|
+
const targetId = normalizedPrefix
|
|
617
|
+
? `${slugPrefix}${crypto.createHash('sha256').update(workspaceId + ':' + parsed.data.id).digest('hex').slice(0, 12)}`
|
|
618
|
+
: parsed.data.id;
|
|
619
|
+
idMap.set(parsed.data.id, targetId);
|
|
620
|
+
}
|
|
621
|
+
presentSlugs.add(targetPath.replace(/\.md$/, ''));
|
|
622
|
+
presentSlugs.add(targetPath.replace(/.*\//, '').replace(/\.md$/, ''));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// 4. Second pass: apply parameter bindings, rewrite IDs/references, reserialize
|
|
626
|
+
const bindings = [];
|
|
627
|
+
const processedFiles = [];
|
|
628
|
+
|
|
629
|
+
for (const { file: f, parsed } of fileParsedList) {
|
|
630
|
+
const data = { ...parsed.data };
|
|
631
|
+
let body = parsed.body;
|
|
632
|
+
const targetPath = remapPath(f.path);
|
|
633
|
+
|
|
634
|
+
// Apply explicit x_bind frontmatter parameter bindings (G1 §16.5.1)
|
|
635
|
+
if (data.x_bind && typeof data.x_bind === 'object' && !Array.isArray(data.x_bind)) {
|
|
636
|
+
for (const [field, paramKey] of Object.entries(data.x_bind)) {
|
|
637
|
+
const paramDef = paramDefMap.get(paramKey);
|
|
638
|
+
const val = paramMap.has(paramKey)
|
|
639
|
+
? paramMap.get(paramKey)
|
|
640
|
+
: (parameters[paramKey] !== undefined ? parameters[paramKey] : paramDef?.defaultValue);
|
|
641
|
+
if (val === undefined) {
|
|
642
|
+
if (paramDef?.required) unresolved.push(paramKey);
|
|
643
|
+
} else {
|
|
644
|
+
data[field] = val;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
delete data.x_bind;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Apply resource_bound requirement parameter bindings (D4 §17.1)
|
|
651
|
+
for (const [k, v] of Object.entries(data)) {
|
|
652
|
+
if (v === 'REQUIREMENT') {
|
|
653
|
+
const aliases = resourceFieldParameterAliases(data.type, k);
|
|
654
|
+
const matchedKey = aliases.find((alias) => parameters[alias] !== undefined);
|
|
655
|
+
const resolvedVal = matchedKey ? parameters[matchedKey] : null;
|
|
656
|
+
if (resolvedVal !== null) {
|
|
657
|
+
data[k] = resolvedVal;
|
|
658
|
+
}
|
|
659
|
+
const matchingStep = (m.provisioning || []).find((s) => s.id === `bind-${data.type}` || s.system === RESOURCE_SYSTEM_BY_KIND[data.type]);
|
|
660
|
+
const def = resolvePrimitiveDefinition(registrySet, data.type);
|
|
661
|
+
const relation = def?.resource?.provision_relation || def?.resource?.binds?.[0] || k;
|
|
662
|
+
const mode = matchingStep?.mode || 'provision';
|
|
663
|
+
const paramDef = paramDefMap.get(matchedKey || aliases[0]) || aliases.map((a) => paramDefMap.get(a)).find(Boolean);
|
|
664
|
+
const isRequired = paramDef ? !!paramDef.required : false;
|
|
665
|
+
|
|
666
|
+
bindings.push({
|
|
667
|
+
path: targetPath,
|
|
668
|
+
type: data.type,
|
|
669
|
+
field: k,
|
|
670
|
+
relation,
|
|
671
|
+
parameter_key: matchedKey || aliases[0],
|
|
672
|
+
value: resolvedVal,
|
|
673
|
+
mode,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
if (resolvedVal === null && isRequired && !dryRun) {
|
|
677
|
+
errors.push(`unbound required resource '${data.type}.${k}' for '${targetPath}' (parameter '${matchedKey || aliases[0]}')`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Remap frontmatter slug / id fields
|
|
683
|
+
if (normalizedPrefix) {
|
|
684
|
+
if (data.slug && slugMap.has(data.slug)) data.slug = slugMap.get(data.slug);
|
|
685
|
+
if (data.id && idMap.has(data.id)) data.id = idMap.get(data.id);
|
|
686
|
+
if (data.workspace_id) data.workspace_id = workspaceId;
|
|
687
|
+
|
|
688
|
+
// Remap frontmatter typed reference paths (D3 §17.3)
|
|
689
|
+
const def = resolvePrimitiveDefinition(registrySet, data.type);
|
|
690
|
+
const declaredRefs = def?.references ? Object.keys(def.references) : [];
|
|
691
|
+
const refFields = new Set(['resource_ref', 'target_workflow', ...declaredRefs]);
|
|
692
|
+
for (const refField of refFields) {
|
|
693
|
+
if (typeof data[refField] === 'string' && !data[refField].startsWith('/') && !data[refField].startsWith('http')) {
|
|
694
|
+
data[refField] = remapPath(data[refField]);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Remap body links (D3 §17.3)
|
|
699
|
+
body = body.replaceAll(/\[\[([^\]]+)\]\]/g, (match, slug) => {
|
|
700
|
+
const remapped = slugMap.get(slug.trim());
|
|
701
|
+
return remapped ? `[[${remapped}]]` : match;
|
|
702
|
+
});
|
|
703
|
+
body = body.replaceAll(/\[([^\]]*)\]\(([^)]+\.md)\)/g, (match, text, rel) => {
|
|
704
|
+
if (!rel.startsWith('/') && !rel.startsWith('http')) return `[${text}](${remapPath(rel)})`;
|
|
705
|
+
return match;
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const rewrittenContent = serializeDocument(data, body);
|
|
710
|
+
processedFiles.push({ path: targetPath, content: rewrittenContent });
|
|
465
711
|
}
|
|
712
|
+
|
|
713
|
+
// 5. Check link integrity
|
|
466
714
|
const danglingLinks = [];
|
|
467
|
-
for (const
|
|
468
|
-
for (const ref of internalRefs(
|
|
469
|
-
if (!presentSlugs.has(ref)) danglingLinks.push({ file:
|
|
715
|
+
for (const pf of processedFiles) {
|
|
716
|
+
for (const ref of internalRefs(pf.content)) {
|
|
717
|
+
if (!presentSlugs.has(ref)) danglingLinks.push({ file: pf.path, ref });
|
|
470
718
|
}
|
|
471
719
|
}
|
|
472
720
|
|
|
473
|
-
// Build
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
721
|
+
// 6. Build operation plan with expected_before_hash (D8 §6.1 / §17.1)
|
|
722
|
+
const plan = processedFiles.map((pf) => {
|
|
723
|
+
const expectedBeforeHash = expectedHashes[pf.path];
|
|
724
|
+
const contentDigest = crypto.createHash('sha256').update(pf.content).digest('hex');
|
|
725
|
+
const key = 'prov-' + crypto.createHash('sha256').update(workspaceId + ':' + pf.path + ':' + contentDigest).digest('hex').slice(0, 24);
|
|
726
|
+
const envelope = {
|
|
727
|
+
type: 'operation',
|
|
728
|
+
idempotency_key: key,
|
|
729
|
+
path: pf.path,
|
|
730
|
+
workspace_id: workspaceId,
|
|
731
|
+
content: pf.content,
|
|
732
|
+
intent: 'provision',
|
|
733
|
+
dry_run: dryRun,
|
|
734
|
+
actor: { role: 'system' },
|
|
735
|
+
};
|
|
736
|
+
if (expectedBeforeHash !== undefined) {
|
|
737
|
+
envelope.expected_before_hash = expectedBeforeHash;
|
|
738
|
+
}
|
|
739
|
+
return envelope;
|
|
481
740
|
});
|
|
482
741
|
|
|
483
|
-
const
|
|
484
|
-
|
|
742
|
+
const combinedPlan = [...dependencyPlans, ...plan];
|
|
743
|
+
const combinedBindings = [...dependencyBindings, ...bindings];
|
|
744
|
+
const combinedSteps = [...dependencySteps, ...(m.provisioning || [])];
|
|
745
|
+
|
|
746
|
+
const ok = unresolved.length === 0 && danglingLinks.length === 0 && errors.length === 0;
|
|
747
|
+
return {
|
|
748
|
+
ok,
|
|
749
|
+
plan: combinedPlan,
|
|
750
|
+
unresolved: [...new Set(unresolved)],
|
|
751
|
+
danglingLinks,
|
|
752
|
+
bindings: combinedBindings,
|
|
753
|
+
dependencies: resolvedDependencies,
|
|
754
|
+
steps: combinedSteps,
|
|
755
|
+
errors,
|
|
756
|
+
};
|
|
485
757
|
}
|
|
486
758
|
|
|
487
759
|
/**
|
|
@@ -543,3 +815,173 @@ export async function importBundle(plan, vaultRoot, engine) {
|
|
|
543
815
|
const committed = results.filter((r) => r.success && !r.replay && !r.dry_run && !r.unchanged).length;
|
|
544
816
|
return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
|
|
545
817
|
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* upgradeBundle(installed, next, opts) → replayable plan of structural patch envelopes (§17.4).
|
|
821
|
+
*
|
|
822
|
+
* Moving an installed bundle to a new version produces only `patch` envelopes,
|
|
823
|
+
* and only for structural paths. Never writes `tenant_private` data.
|
|
824
|
+
* Records the upgrade in a `migration` primitive document.
|
|
825
|
+
*/
|
|
826
|
+
export function upgradeBundle(installed, next, opts = {}) {
|
|
827
|
+
const {
|
|
828
|
+
migration = null,
|
|
829
|
+
workspaceId = 'ws-upgrade',
|
|
830
|
+
dryRun = false,
|
|
831
|
+
registryDir,
|
|
832
|
+
pathPrefix = '',
|
|
833
|
+
} = opts;
|
|
834
|
+
|
|
835
|
+
const nextValidation = validateBundle(next, { registryDir });
|
|
836
|
+
if (!nextValidation.valid) {
|
|
837
|
+
return { ok: false, plan: [], errors: nextValidation.errors, migration: null, patchesCount: 0 };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const mNext = next.manifest;
|
|
841
|
+
const fromVersion = installed?.manifest?.version || opts.fromVersion || '1.0.0';
|
|
842
|
+
const toVersion = mNext.version || opts.toVersion || '1.1.0';
|
|
843
|
+
|
|
844
|
+
const declaredExts = mNext.required_extensions;
|
|
845
|
+
const registryNames = Array.isArray(declaredExts) && declaredExts.length ? declaredExts : null;
|
|
846
|
+
const registrySet = loadRegistries(registryDir, registryNames ? { only: registryNames } : {});
|
|
847
|
+
|
|
848
|
+
const normalizedPrefix = pathPrefix ? pathPrefix.replace(/\/$/, '') : '';
|
|
849
|
+
const remapPath = (rel) => (normalizedPrefix ? `${normalizedPrefix}/${rel}` : rel);
|
|
850
|
+
|
|
851
|
+
const installedFiles = Array.isArray(installed) ? installed : (installed?.files || []);
|
|
852
|
+
const installedMap = new Map(installedFiles.map((f) => [f.path, f]));
|
|
853
|
+
|
|
854
|
+
const errors = [];
|
|
855
|
+
const patchEnvelopes = [];
|
|
856
|
+
const modifiedPaths = [];
|
|
857
|
+
|
|
858
|
+
for (const nextFile of next.files) {
|
|
859
|
+
const targetPath = remapPath(nextFile.path);
|
|
860
|
+
const parsed = parseDocument(nextFile.content);
|
|
861
|
+
const def = resolvePrimitiveDefinition(registrySet, parsed.data.type);
|
|
862
|
+
const klass = resolvePortability(def, parsed.data);
|
|
863
|
+
|
|
864
|
+
if (klass === 'tenant_private') {
|
|
865
|
+
// An upgrade bundle must never carry or modify tenant_private files
|
|
866
|
+
if (installedMap.has(nextFile.path) || installedMap.has(targetPath)) {
|
|
867
|
+
errors.push(`Upgrade may not target tenant_private file '${targetPath}'`);
|
|
868
|
+
}
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const installedFile = installedMap.get(targetPath) || installedMap.get(nextFile.path);
|
|
873
|
+
if (!installedFile) {
|
|
874
|
+
// New structural file in next bundle
|
|
875
|
+
const key = 'upg-create-' + crypto.createHash('sha256')
|
|
876
|
+
.update(`${workspaceId}:${targetPath}:${toVersion}`)
|
|
877
|
+
.digest('hex')
|
|
878
|
+
.slice(0, 24);
|
|
879
|
+
patchEnvelopes.push({
|
|
880
|
+
type: 'operation',
|
|
881
|
+
idempotency_key: key,
|
|
882
|
+
path: targetPath,
|
|
883
|
+
workspace_id: workspaceId,
|
|
884
|
+
content: nextFile.content,
|
|
885
|
+
intent: 'upgrade',
|
|
886
|
+
dry_run: dryRun,
|
|
887
|
+
actor: { role: 'system' },
|
|
888
|
+
expected_before_hash: null,
|
|
889
|
+
});
|
|
890
|
+
modifiedPaths.push(targetPath);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (installedFile.content === nextFile.content) {
|
|
895
|
+
continue; // identical, unchanged
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Compute diff between installedFile and nextFile
|
|
899
|
+
const installedParsed = parseDocument(installedFile.content);
|
|
900
|
+
const patches = {};
|
|
901
|
+
|
|
902
|
+
for (const [key, val] of Object.entries(parsed.data)) {
|
|
903
|
+
if (JSON.stringify(installedParsed.data[key]) !== JSON.stringify(val)) {
|
|
904
|
+
patches[key] = val;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
for (const key of Object.keys(installedParsed.data)) {
|
|
908
|
+
if (!(key in parsed.data)) {
|
|
909
|
+
patches[key] = null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (installedParsed.body !== parsed.body) {
|
|
913
|
+
patches.__body__ = parsed.body;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (Object.keys(patches).length > 0) {
|
|
917
|
+
const canonicalInstalled = serializeDocument(installedParsed.data, installedParsed.body);
|
|
918
|
+
const contentHashBefore = opts.expectedHashes?.[targetPath] || ('sha256:' + crypto.createHash('sha256')
|
|
919
|
+
.update(Buffer.from(canonicalInstalled, 'utf8'))
|
|
920
|
+
.digest('hex'));
|
|
921
|
+
const key = 'upg-patch-' + crypto.createHash('sha256')
|
|
922
|
+
.update(`${workspaceId}:${targetPath}:${toVersion}:${contentHashBefore}`)
|
|
923
|
+
.digest('hex')
|
|
924
|
+
.slice(0, 24);
|
|
925
|
+
patchEnvelopes.push({
|
|
926
|
+
type: 'patch',
|
|
927
|
+
idempotency_key: key,
|
|
928
|
+
path: targetPath,
|
|
929
|
+
workspace_id: workspaceId,
|
|
930
|
+
patches,
|
|
931
|
+
intent: 'upgrade',
|
|
932
|
+
dry_run: dryRun,
|
|
933
|
+
actor: { role: 'system' },
|
|
934
|
+
expected_before_hash: contentHashBefore,
|
|
935
|
+
});
|
|
936
|
+
modifiedPaths.push(targetPath);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Create migration primitive document record
|
|
941
|
+
const migrationId = migration?.migration_id || `mig-${fromVersion}-to-${toVersion}`.replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
942
|
+
const migrationTitle = migration?.title || `Upgrade from ${fromVersion} to ${toVersion}`;
|
|
943
|
+
const migrationDesc = migration?.description || `Structural upgrade applying ${patchEnvelopes.length} patch(es).`;
|
|
944
|
+
const migrationTime = migration?.timestamp || new Date().toISOString();
|
|
945
|
+
const migrationPath = remapPath(`migrations/${migrationId}.md`);
|
|
946
|
+
|
|
947
|
+
const migrationData = {
|
|
948
|
+
type: 'migration',
|
|
949
|
+
title: migrationTitle,
|
|
950
|
+
description: migrationDesc,
|
|
951
|
+
timestamp: migrationTime,
|
|
952
|
+
migration_id: migrationId,
|
|
953
|
+
from_version: fromVersion,
|
|
954
|
+
to_version: toVersion,
|
|
955
|
+
status: 'applied',
|
|
956
|
+
};
|
|
957
|
+
const migrationBody = `# Migration ${migrationId}\n\nUpgraded capability bundle from ${fromVersion} to ${toVersion}.\nApplied ${patchEnvelopes.length} structural patch(es):\n${modifiedPaths.map((p) => `- ${p}`).join('\n')}\n`;
|
|
958
|
+
const migrationContent = serializeDocument(migrationData, migrationBody);
|
|
959
|
+
|
|
960
|
+
const migrationKey = 'upg-mig-' + crypto.createHash('sha256')
|
|
961
|
+
.update(`${workspaceId}:${migrationPath}:${migrationId}`)
|
|
962
|
+
.digest('hex')
|
|
963
|
+
.slice(0, 24);
|
|
964
|
+
|
|
965
|
+
const migrationEnvelope = {
|
|
966
|
+
type: 'operation',
|
|
967
|
+
idempotency_key: migrationKey,
|
|
968
|
+
path: migrationPath,
|
|
969
|
+
workspace_id: workspaceId,
|
|
970
|
+
content: migrationContent,
|
|
971
|
+
intent: 'upgrade',
|
|
972
|
+
dry_run: dryRun,
|
|
973
|
+
actor: { role: 'system' },
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
const plan = [...patchEnvelopes, migrationEnvelope];
|
|
977
|
+
return {
|
|
978
|
+
ok: errors.length === 0,
|
|
979
|
+
plan,
|
|
980
|
+
fromVersion,
|
|
981
|
+
toVersion,
|
|
982
|
+
migration: { id: migrationId, path: migrationPath },
|
|
983
|
+
patchesCount: patchEnvelopes.filter((e) => e.type === 'patch').length,
|
|
984
|
+
operationsCount: patchEnvelopes.filter((e) => e.type === 'operation').length + 1,
|
|
985
|
+
errors,
|
|
986
|
+
};
|
|
987
|
+
}
|
package/src/engine.mjs
CHANGED
|
@@ -39,7 +39,17 @@ export function legacyRoleCapabilities(permissions, registrySet) {
|
|
|
39
39
|
if (permission === 'write:*' || permission === '*:*') { capabilities.add('*:*'); continue; }
|
|
40
40
|
const match = permission.match(/^(write|\*):(.+)$/);
|
|
41
41
|
const definition = match && resolvePrimitiveDefinition(registrySet, match[2]);
|
|
42
|
-
if (definition?.qualified_type)
|
|
42
|
+
if (definition?.qualified_type) {
|
|
43
|
+
capabilities.add(`${definition.qualified_type}:*`);
|
|
44
|
+
const prefix = definition.qualified_type.includes(':')
|
|
45
|
+
? definition.qualified_type.split(':')[0]
|
|
46
|
+
: 'ssss';
|
|
47
|
+
for (const alias of definition.aliases || []) {
|
|
48
|
+
capabilities.add(`${prefix}:${alias}:*`);
|
|
49
|
+
capabilities.add(`write:${alias}`);
|
|
50
|
+
capabilities.add(`*:${alias}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
43
53
|
}
|
|
44
54
|
return [...capabilities];
|
|
45
55
|
}
|
package/src/kernel.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
7
7
|
import { createValidator } from './validator.mjs';
|
|
8
|
-
import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './registry.mjs';
|
|
8
|
+
import { isAppendType, isSafeDocumentPath, resolvePortability, resolvePrimitiveDefinition, isSamePrimitiveType } from './registry.mjs';
|
|
9
9
|
import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
|
|
10
10
|
import { createCanonicalEvent, idempotentEventId } from './events.mjs';
|
|
11
11
|
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
@@ -44,6 +44,7 @@ export function canonicalRequestHash(envelope, principal) {
|
|
|
44
44
|
primitive_type: envelope?.primitive_type,
|
|
45
45
|
content: envelope?.content,
|
|
46
46
|
patches: envelope?.patches,
|
|
47
|
+
expected_before_hash: envelope?.expected_before_hash ?? null,
|
|
47
48
|
verified_principal_id: principal?.id || null,
|
|
48
49
|
};
|
|
49
50
|
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(clean))).digest('hex')}`;
|
|
@@ -243,6 +244,23 @@ export function createKernel(options = {}) {
|
|
|
243
244
|
let nextData = null;
|
|
244
245
|
let currentData = null;
|
|
245
246
|
|
|
247
|
+
if (envelope.expected_before_hash !== undefined) {
|
|
248
|
+
const expected = envelope.expected_before_hash;
|
|
249
|
+
if (!current) {
|
|
250
|
+
if (expected !== null) {
|
|
251
|
+
return failure('version_conflict', envelope, operationId, [`Document does not exist; expected before hash '${expected}'.`], [{ field: 'expected_before_hash', issue: 'Expected document to exist.' }]);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
if (current.hash !== expected) {
|
|
255
|
+
if (typeof envelope.content === 'string' && current.bytes.toString('utf8') === envelope.content) {
|
|
256
|
+
// Replaying identical content is an unchanged no-op
|
|
257
|
+
} else {
|
|
258
|
+
return failure('version_conflict', envelope, operationId, [`Document hash '${current.hash}' does not match expected_before_hash '${expected}'.`], [{ field: 'expected_before_hash', issue: 'Hash mismatch.' }]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
246
264
|
if (current) {
|
|
247
265
|
try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
|
|
248
266
|
catch (error) { return failure('internal_error', envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
|
|
@@ -266,7 +284,7 @@ export function createKernel(options = {}) {
|
|
|
266
284
|
nextContent = envelope.content;
|
|
267
285
|
nextData = validation.data;
|
|
268
286
|
}
|
|
269
|
-
if (currentData?.type && currentData.type
|
|
287
|
+
if (currentData?.type && !isSamePrimitiveType(registrySet, currentData.type, nextData.type)) {
|
|
270
288
|
return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
271
289
|
}
|
|
272
290
|
// Replaying an identical full operation is a no-op, not a replace. This must
|
|
@@ -276,7 +294,7 @@ export function createKernel(options = {}) {
|
|
|
276
294
|
const noopAuth = await authorize({
|
|
277
295
|
principal, workspaceId: envelope.workspace_id, definition,
|
|
278
296
|
action: envelope.type === 'operation' ? 'replace' : actionFor(envelope, true, isAppendType(definition)),
|
|
279
|
-
context,
|
|
297
|
+
context: { ...context, path: envelope.path },
|
|
280
298
|
});
|
|
281
299
|
if (!noopAuth.allowed) {
|
|
282
300
|
const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
|
|
@@ -299,7 +317,7 @@ export function createKernel(options = {}) {
|
|
|
299
317
|
if (!current) return failure('not_found', envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
300
318
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
301
319
|
if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
|
|
302
|
-
if (Object.hasOwn(envelope.patches, 'type') && envelope.patches.type
|
|
320
|
+
if (Object.hasOwn(envelope.patches, 'type') && !isSamePrimitiveType(registrySet, envelope.patches.type, currentData.type)) {
|
|
303
321
|
return failure('validation_failed', envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
304
322
|
}
|
|
305
323
|
nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
|
|
@@ -329,9 +347,16 @@ export function createKernel(options = {}) {
|
|
|
329
347
|
catch { return failure('invalid_request', envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
|
|
330
348
|
}
|
|
331
349
|
|
|
350
|
+
if (envelope.intent === 'upgrade' && definition && envelope.type !== 'event') {
|
|
351
|
+
const klass = resolvePortability(definition, currentData || nextData);
|
|
352
|
+
if (klass === 'tenant_private') {
|
|
353
|
+
return failure('forbidden', envelope, operationId, [`Upgrade may not modify tenant_private document '${envelope.path}'.`], [{ field: 'path', issue: 'tenant_private documents are immutable during upgrades.' }]);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
332
357
|
const appendOnly = isAppendType(definition);
|
|
333
358
|
const action = actionFor(envelope, !!current, appendOnly);
|
|
334
|
-
const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
|
|
359
|
+
const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context: { ...context, path: envelope.path } });
|
|
335
360
|
if (!auth.allowed) {
|
|
336
361
|
// An absent or malformed principal is an authentication failure, not a permission one.
|
|
337
362
|
const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
|