@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/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 { parameters = {}, workspaceId = 'ws-provision', pathPrefix = '', dryRun = false } = opts;
440
- const validation = validateBundle(bundle, { registryDir: opts.registryDir });
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
- if (p.required && parameters[p.key] === undefined && p.defaultValue === undefined) unresolved.push(p.key);
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
- // Id-remap: every file is placed under the target prefix; build the slug set so
453
- // we can verify link integrity (§17.3) against the remapped target.
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 remap = (rel) => (normalizedPrefix ? `${normalizedPrefix}/${rel}` : rel);
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 { data } = parseDocument(f.content);
462
- if (data.slug) presentSlugs.add(data.slug);
463
- presentSlugs.add(f.path.replace(/\.md$/, ''));
464
- presentSlugs.add(f.path.replace(/.*\//, '').replace(/\.md$/, ''));
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 f of bundle.files) {
468
- for (const ref of internalRefs(f.content)) {
469
- if (!presentSlugs.has(ref)) danglingLinks.push({ file: f.path, ref });
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 the envelope plan. Deterministic idempotency keys make import a no-op on re-run.
474
- // The key covers the content: committed keys are durable (spec §6.4), so a key
475
- // derived from the path alone would make every changed re-import a conflict.
476
- const plan = bundle.files.map((f) => {
477
- const targetPath = remap(f.path);
478
- const contentDigest = crypto.createHash('sha256').update(f.content).digest('hex');
479
- const key = 'prov-' + crypto.createHash('sha256').update(workspaceId + ':' + targetPath + ':' + contentDigest).digest('hex').slice(0, 24);
480
- return { type: 'operation', idempotency_key: key, path: targetPath, workspace_id: workspaceId, content: f.content, intent: 'provision', dry_run: dryRun, actor: { role: 'system' } };
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 ok = unresolved.length === 0 && danglingLinks.length === 0;
484
- return { ok, plan, unresolved, danglingLinks, steps: m.provisioning || [] };
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) capabilities.add(`${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 !== nextData.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 !== currentData.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';