@aikdna/kdna-cli 0.17.0 → 0.18.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/src/loader.js CHANGED
@@ -15,8 +15,8 @@ const FILE_MAP = core.FILE_MAP;
15
15
  * Read and parse a KDNA JSON file.
16
16
  * Returns null if the file does not exist.
17
17
  */
18
- function readFile(domainDir, filename) {
19
- const filePath = path.join(domainDir, filename);
18
+ function readFile(sourceDir, filename) {
19
+ const filePath = path.join(sourceDir, filename);
20
20
  if (!fs.existsSync(filePath)) return null;
21
21
  try {
22
22
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -29,32 +29,32 @@ function readFile(domainDir, filename) {
29
29
  * Load the minimum required KDNA files (Core + Patterns).
30
30
  * Always load these. They form the cognition baseline.
31
31
  */
32
- function loadCorePatterns(domainDir) {
33
- const coreData = readFile(domainDir, FILE_MAP.core);
34
- const patternsData = readFile(domainDir, FILE_MAP.patterns);
32
+ function loadCorePatterns(sourceDir) {
33
+ const coreData = readFile(sourceDir, FILE_MAP.core);
34
+ const patternsData = readFile(sourceDir, FILE_MAP.patterns);
35
35
  return core.loadCorePatternsFromData(coreData, patternsData);
36
36
  }
37
37
 
38
38
  /**
39
39
  * Load a complete KDNA domain.
40
40
  *
41
- * @param {string} domainDir — path to the domain folder
41
+ * @param {string} sourceDir — path to a dev source directory
42
42
  * @param {object} [options]
43
43
  * @param {string} [options.input] — user input text for conditional loading
44
44
  * @param {'all'|'minimum'|'auto'} [options.mode='auto'] — loading mode
45
45
  * @returns {object|null} loaded KDNA files keyed by type, or null if minimum files are missing
46
46
  */
47
- function loadDomain(domainDir, options = {}) {
47
+ function loadDomain(sourceDir, options = {}) {
48
48
  const dataMap = { core: null, patterns: null };
49
49
 
50
- dataMap.core = readFile(domainDir, FILE_MAP.core);
51
- dataMap.patterns = readFile(domainDir, FILE_MAP.patterns);
50
+ dataMap.core = readFile(sourceDir, FILE_MAP.core);
51
+ dataMap.patterns = readFile(sourceDir, FILE_MAP.patterns);
52
52
 
53
53
  if (!dataMap.core || !dataMap.patterns) return null;
54
54
 
55
55
  // Also read optional files that might be present
56
56
  for (const key of ['scenarios', 'cases', 'reasoning', 'evolution']) {
57
- const data = readFile(domainDir, FILE_MAP[key]);
57
+ const data = readFile(sourceDir, FILE_MAP[key]);
58
58
  if (data) dataMap[key] = data;
59
59
  }
60
60
 
@@ -0,0 +1,229 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const PATHS = require('./paths');
5
+ const { parseName } = require('./registry');
6
+ const core = require('@aikdna/kdna-core');
7
+
8
+ if (typeof core.createKdnaAssetReader !== 'function') {
9
+ throw new Error('@aikdna/kdna-core >=0.5.0 is required for direct .kdna asset loading');
10
+ }
11
+
12
+ const assetReader = core.createKdnaAssetReader();
13
+
14
+ const INDEX_VERSION = 2;
15
+
16
+ function ensureDir(dir) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ }
19
+
20
+ function readJsonFile(file) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function writeJsonFile(file, data) {
29
+ ensureDir(path.dirname(file));
30
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
31
+ }
32
+
33
+ function readIndex() {
34
+ const data = readJsonFile(PATHS.packageIndex);
35
+ if (data?.packages && typeof data.packages === 'object') return data;
36
+ return { version: INDEX_VERSION, packages: {} };
37
+ }
38
+
39
+ function writeIndex(index) {
40
+ index.version = INDEX_VERSION;
41
+ writeJsonFile(PATHS.packageIndex, index);
42
+ }
43
+
44
+ function sha256File(filePath) {
45
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
46
+ }
47
+
48
+ function assetDigest(filePath) {
49
+ return `sha256:${sha256File(filePath)}`;
50
+ }
51
+
52
+ function contentDigest(kdnaPath) {
53
+ return assetReader.contentDigestSync(assetReader.openSync(kdnaPath));
54
+ }
55
+
56
+ function assetDir(scope, ident, version) {
57
+ return path.join(PATHS.packages, scope, ident, version || 'unknown');
58
+ }
59
+
60
+ function assetFileName(ident, version) {
61
+ return `${ident}-${version || 'unknown'}.kdna`;
62
+ }
63
+
64
+ function readContainerJson(kdnaPath, fileName, options = {}) {
65
+ const asset = assetReader.openSync(kdnaPath);
66
+ return assetReader.readJsonSync(asset, fileName, options);
67
+ }
68
+
69
+ function readContainerEntry(kdnaPath, fileName) {
70
+ const asset = assetReader.openSync(kdnaPath);
71
+ return assetReader.readEntrySync(asset, fileName);
72
+ }
73
+
74
+ function listContainerEntries(kdnaPath) {
75
+ const asset = assetReader.openSync(kdnaPath);
76
+ return assetReader.listEntriesSync(asset);
77
+ }
78
+
79
+ function readContainer(kdnaPath, options = {}) {
80
+ const asset = assetReader.openSync(kdnaPath);
81
+ return {
82
+ manifest: assetReader.readJsonSync(asset, 'kdna.json'),
83
+ core: assetReader.readJsonSync(asset, 'KDNA_Core.json', options),
84
+ patterns: assetReader.readJsonSync(asset, 'KDNA_Patterns.json', options),
85
+ scenarios: assetReader.readJsonSync(asset, 'KDNA_Scenarios.json', options),
86
+ cases: assetReader.readJsonSync(asset, 'KDNA_Cases.json', options),
87
+ reasoning: assetReader.readJsonSync(asset, 'KDNA_Reasoning.json', options),
88
+ evolution: assetReader.readJsonSync(asset, 'KDNA_Evolution.json', options),
89
+ files: assetReader.listEntriesSync(asset),
90
+ };
91
+ }
92
+
93
+ function verifyAsset(kdnaPath, options = {}) {
94
+ const asset = assetReader.openSync(kdnaPath);
95
+ return assetReader.verifySync(asset, options);
96
+ }
97
+
98
+ function getInstalled(input) {
99
+ const parsed = parseName(input);
100
+ if (!parsed) return null;
101
+ const index = readIndex();
102
+ const entry = index.packages[parsed.full];
103
+ if (!entry?.asset_path || !fs.existsSync(entry.asset_path)) return null;
104
+ return { ...entry, parsed };
105
+ }
106
+
107
+ function listInstalled() {
108
+ const index = readIndex();
109
+ return Object.entries(index.packages)
110
+ .map(([full, entry]) => ({ full, ...entry }))
111
+ .filter((entry) => entry.asset_path && fs.existsSync(entry.asset_path))
112
+ .sort((a, b) => a.full.localeCompare(b.full));
113
+ }
114
+
115
+ function readAssetManifest(assetPath) {
116
+ return readContainerJson(assetPath, 'kdna.json') || {};
117
+ }
118
+
119
+ function receiptPathForAsset(assetPath) {
120
+ return path.join(path.dirname(assetPath), 'receipt.json');
121
+ }
122
+
123
+ function installAsset({ sourcePath, name, version, source = {} }) {
124
+ const parsed = parseName(name);
125
+ if (!parsed) throw new Error(`Invalid scoped domain name: ${name}`);
126
+ const finalVersion = version || 'unknown';
127
+ const destDir = assetDir(parsed.scope, parsed.ident, finalVersion);
128
+ const dest = path.join(destDir, assetFileName(parsed.ident, finalVersion));
129
+ ensureDir(destDir);
130
+ fs.copyFileSync(sourcePath, dest);
131
+
132
+ const manifest = readAssetManifest(dest);
133
+ const installedAt = new Date().toISOString();
134
+ const computedAssetDigest = assetDigest(dest);
135
+ const computedContentDigest = contentDigest(dest);
136
+ const receiptPath = receiptPathForAsset(dest);
137
+ const receipt = {
138
+ version: 1,
139
+ name: parsed.full,
140
+ asset_path: dest,
141
+ asset_digest: computedAssetDigest,
142
+ content_digest: computedContentDigest,
143
+ package_version: finalVersion,
144
+ judgment_version: manifest.judgment_version || null,
145
+ access: manifest.access || 'open',
146
+ signature: manifest.signature || null,
147
+ installed_at: installedAt,
148
+ source,
149
+ };
150
+ writeJsonFile(receiptPath, receipt);
151
+
152
+ const index = readIndex();
153
+ index.packages[parsed.full] = {
154
+ name: parsed.full,
155
+ version: finalVersion,
156
+ asset_path: dest,
157
+ receipt_path: receiptPath,
158
+ asset_digest: computedAssetDigest,
159
+ content_digest: computedContentDigest,
160
+ judgment_version: manifest.judgment_version || null,
161
+ access: manifest.access || 'open',
162
+ signature: manifest.signature || null,
163
+ installed_at: installedAt,
164
+ source,
165
+ };
166
+ writeIndex(index);
167
+ return index.packages[parsed.full];
168
+ }
169
+
170
+ function resolveAsset(input) {
171
+ const expanded = input.replace(/^~/, process.env.HOME || '');
172
+ const looksLikeFile =
173
+ input.endsWith('.kdna') &&
174
+ (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/') || fs.existsSync(expanded));
175
+ if (looksLikeFile) {
176
+ const abs = path.resolve(expanded);
177
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
178
+ const manifest = readAssetManifest(abs);
179
+ const full = manifest.name;
180
+ const parsed = full ? parseName(full) : null;
181
+ return {
182
+ name: full || path.basename(abs, '.kdna'),
183
+ parsed,
184
+ asset_path: abs,
185
+ receipt_path: null,
186
+ version: manifest.version || null,
187
+ judgment_version: manifest.judgment_version || null,
188
+ access: manifest.access || 'open',
189
+ asset_digest: assetDigest(abs),
190
+ content_digest: contentDigest(abs),
191
+ source: { type: 'local-file', path: abs },
192
+ local_file: true,
193
+ };
194
+ }
195
+ return getInstalled(input);
196
+ }
197
+
198
+ function removeInstalled(input) {
199
+ const parsed = parseName(input);
200
+ if (!parsed) return false;
201
+ const index = readIndex();
202
+ const entry = index.packages[parsed.full];
203
+ if (!entry) return false;
204
+ delete index.packages[parsed.full];
205
+ writeIndex(index);
206
+ if (entry.asset_path) {
207
+ const versionDir = path.dirname(entry.asset_path);
208
+ fs.rmSync(versionDir, { recursive: true, force: true });
209
+ }
210
+ return true;
211
+ }
212
+
213
+ module.exports = {
214
+ readIndex,
215
+ writeIndex,
216
+ sha256File,
217
+ assetDigest,
218
+ contentDigest,
219
+ readContainer,
220
+ readContainerEntry,
221
+ readContainerJson,
222
+ listContainerEntries,
223
+ verifyAsset,
224
+ getInstalled,
225
+ listInstalled,
226
+ installAsset,
227
+ resolveAsset,
228
+ removeInstalled,
229
+ };
package/src/paths.js ADDED
@@ -0,0 +1,44 @@
1
+ // KDNA shared path configuration — canonical source for ~/.kdna structure
2
+ // Spec: docs/local-kdna-home-spec.md
3
+
4
+ const path = require('path');
5
+
6
+ const KDNA_HOME = process.env.KDNA_HOME
7
+ || path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
8
+
9
+ const PATHS = {
10
+ root: KDNA_HOME,
11
+ config: path.join(KDNA_HOME, 'config.json'),
12
+ identity: path.join(KDNA_HOME, 'identity'),
13
+ domains: {
14
+ root: path.join(KDNA_HOME, 'domains'),
15
+ official: path.join(KDNA_HOME, 'domains', 'official'),
16
+ local: path.join(KDNA_HOME, 'domains', 'local'),
17
+ private: path.join(KDNA_HOME, 'domains', 'private'),
18
+ // Legacy flat path — used for migration only
19
+ legacy: path.join(KDNA_HOME, 'domains'),
20
+ // All three directories for scanning
21
+ all: [
22
+ path.join(KDNA_HOME, 'domains', 'official'),
23
+ path.join(KDNA_HOME, 'domains', 'local'),
24
+ path.join(KDNA_HOME, 'domains', 'private'),
25
+ ],
26
+ },
27
+ clusters: path.join(KDNA_HOME, 'clusters'),
28
+ packages: path.join(KDNA_HOME, 'packages'),
29
+ packageIndex: path.join(KDNA_HOME, 'index.json'),
30
+ registry: path.join(KDNA_HOME, 'registry'),
31
+ registryCache: path.join(KDNA_HOME, 'registry', 'cache.json'),
32
+ traces: path.join(KDNA_HOME, 'traces'),
33
+ feedback: path.join(KDNA_HOME, 'feedback'),
34
+ evals: path.join(KDNA_HOME, 'evals'),
35
+ cache: path.join(KDNA_HOME, 'cache'),
36
+ licenses: path.join(KDNA_HOME, 'licenses'),
37
+ };
38
+
39
+ // Runtime asset store aliases
40
+ PATHS.USER_KDNA_DIR = KDNA_HOME;
41
+ PATHS.INSTALL_DIR = PATHS.packages;
42
+
43
+ module.exports = PATHS;
44
+ module.exports.KDNA_HOME = KDNA_HOME;
package/src/publish.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { EXIT } = require('./cmds/_common');
10
+ const { EXIT, selfCheckText, isYesNoSelfCheck } = require('./cmds/_common');
11
11
 
12
12
  function error(msg, code = EXIT.VALIDATION_FAILED) {
13
13
  console.error(`Error: ${msg}`);
@@ -143,8 +143,6 @@ function isGenericSelfCheck(question) {
143
143
 
144
144
  // ─── Human Lock Gate ──────────────────────────────────────────────────
145
145
 
146
- const JUDGMENT_CARD_TYPES = ['axiom', 'boundary', 'risk', 'aesthetic'];
147
-
148
146
  /**
149
147
  * Check whether the domain satisfies Human Lock requirements.
150
148
  * Returns { passed, issues[] } — publish should be blocked if !passed.
@@ -384,21 +382,22 @@ function cmdPublishCheck(domainPath, args = []) {
384
382
  }
385
383
  for (let i = 0; i < core.stances.length; i++) {
386
384
  const s = core.stances[i];
387
- if (typeof s !== 'string') {
385
+ const stanceText = typeof s === 'string' ? s : s && typeof s === 'object' ? s.stance : null;
386
+ if (!stanceText || typeof stanceText !== 'string') {
388
387
  fail(
389
388
  'KDNA_Core.json',
390
389
  `stances[${i}]`,
391
390
  JSON.stringify(s),
392
- 'Must be a string, not an object.',
391
+ 'Must be a string or an object with a stance string.',
393
392
  );
394
- } else if (isSlogan(s)) {
393
+ } else if (isSlogan(stanceText)) {
395
394
  fail(
396
395
  'KDNA_Core.json',
397
396
  `stances[${i}]`,
398
- s,
397
+ stanceText,
399
398
  'Reads like a slogan. Stances must be prescriptive positions that bias agent behavior.',
400
399
  );
401
- } else if (isVague(s)) {
400
+ } else if (isVague(stanceText)) {
402
401
  warn('KDNA_Core.json', `stances[${i}]`, 'Contains vague language.');
403
402
  } else {
404
403
  pass('KDNA_Core.json', `stances[${i}]`);
@@ -442,22 +441,23 @@ function cmdPublishCheck(domainPath, args = []) {
442
441
  if (patterns.self_check && Array.isArray(patterns.self_check)) {
443
442
  for (let i = 0; i < patterns.self_check.length; i++) {
444
443
  const sc = patterns.self_check[i];
445
- if (typeof sc !== 'string') {
444
+ const text = selfCheckText(sc);
445
+ if (!text) {
446
446
  fail(
447
447
  'KDNA_Patterns.json',
448
448
  `self_check[${i}]`,
449
449
  JSON.stringify(sc),
450
- 'Must be a string, not an object.',
450
+ 'Must be a string or an object with a question string.',
451
451
  );
452
- } else if (isGenericSelfCheck(sc)) {
452
+ } else if (isGenericSelfCheck(text)) {
453
453
  fail(
454
454
  'KDNA_Patterns.json',
455
455
  `self_check[${i}]`,
456
- sc,
456
+ text,
457
457
  'Generic question. Self-checks must be domain-specific, not "is this helpful?".',
458
458
  );
459
- } else if (!sc.endsWith('?')) {
460
- warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should end with a question mark.');
459
+ } else if (!isYesNoSelfCheck(sc)) {
460
+ warn('KDNA_Patterns.json', `self_check[${i}]`, 'Should be answerable with yes/no.');
461
461
  passes++;
462
462
  } else {
463
463
  pass('KDNA_Patterns.json', `self_check[${i}]`);
@@ -531,9 +531,9 @@ function identityPaths() {
531
531
  * inside the .kdna ZIP, joined as `name:hex\n`. This is what the signature covers.
532
532
  *
533
533
  * Excludes the `signature` field from kdna.json itself (computed by removing it
534
- * before hashing). All other files included as-is.
534
+ * before hashing). Digest self-reference fields are also excluded. All other files included as-is.
535
535
  */
536
- function canonicalPayload(srcDir) {
536
+ function canonicalPayload(srcDir, opts = {}) {
537
537
  const files = fs
538
538
  .readdirSync(srcDir)
539
539
  .filter((f) => f.endsWith('.json'))
@@ -545,6 +545,10 @@ function canonicalPayload(srcDir) {
545
545
  if (f === 'kdna.json') {
546
546
  const obj = JSON.parse(fs.readFileSync(full, 'utf8'));
547
547
  delete obj.signature;
548
+ delete obj.asset_digest;
549
+ delete obj.container_sha256;
550
+ if (!opts.includeContentDigest) delete obj.content_digest;
551
+ delete obj._source;
548
552
  buf = Buffer.from(JSON.stringify(obj));
549
553
  } else {
550
554
  buf = fs.readFileSync(full);
@@ -555,6 +559,46 @@ function canonicalPayload(srcDir) {
555
559
  return parts.join('\n');
556
560
  }
557
561
 
562
+ function stableStringify(value) {
563
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
564
+ if (value && typeof value === 'object') {
565
+ return `{${Object.keys(value)
566
+ .sort()
567
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
568
+ .join(',')}}`;
569
+ }
570
+ return JSON.stringify(value);
571
+ }
572
+
573
+ function manifestForContentDigest(manifest) {
574
+ const copy = { ...(manifest || {}) };
575
+ delete copy.signature;
576
+ delete copy.asset_digest;
577
+ delete copy.container_sha256;
578
+ delete copy.content_digest;
579
+ delete copy._source;
580
+ return copy;
581
+ }
582
+
583
+ function sourceContentDigest(srcDir) {
584
+ const files = fs
585
+ .readdirSync(srcDir)
586
+ .filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE')
587
+ .sort();
588
+ const parts = [];
589
+ for (const f of files) {
590
+ let buf;
591
+ if (f === 'kdna.json') {
592
+ const obj = JSON.parse(fs.readFileSync(path.join(srcDir, f), 'utf8'));
593
+ buf = Buffer.from(stableStringify(manifestForContentDigest(obj)));
594
+ } else {
595
+ buf = fs.readFileSync(path.join(srcDir, f));
596
+ }
597
+ parts.push(`${f}:${crypto.createHash('sha256').update(buf).digest('hex')}`);
598
+ }
599
+ return `sha256:${crypto.createHash('sha256').update(Buffer.from(parts.join('\n'))).digest('hex')}`;
600
+ }
601
+
558
602
  function signPayload(payload, privateKeyPem) {
559
603
  const privateKey = crypto.createPrivateKey(privateKeyPem);
560
604
  const sig = crypto.sign(null, Buffer.from(payload), privateKey);
@@ -582,7 +626,7 @@ function packToFile(domainDir, outPath) {
582
626
  const files = fs
583
627
  .readdirSync(domainDir)
584
628
  .filter((f) => f.endsWith('.json') || f === 'README.md' || f === 'LICENSE');
585
- if (!files.includes('kdna.json')) error('kdna.json required in domain folder for publish.');
629
+ if (!files.includes('kdna.json')) error('kdna.json required in dev source directory for publish.');
586
630
 
587
631
  const script = `import zipfile, os
588
632
  src = ${JSON.stringify(domainDir)}
@@ -679,9 +723,14 @@ function cmdPublish(domainPath, args = []) {
679
723
 
680
724
  // 2. Write signature
681
725
  delete manifest.signature;
726
+ delete manifest.asset_digest;
727
+ delete manifest.container_sha256;
728
+ delete manifest.content_digest;
729
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
730
+ manifest.content_digest = sourceContentDigest(abs);
682
731
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
683
- const payload = canonicalPayload(abs);
684
- const sig = signPayload(payload, privateKey);
732
+ const signedPayload = canonicalPayload(abs, { includeContentDigest: true });
733
+ const sig = signPayload(signedPayload, privateKey);
685
734
  manifest.signature = 'ed25519:' + sig;
686
735
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
687
736
  console.log(
@@ -697,9 +746,10 @@ function cmdPublish(domainPath, args = []) {
697
746
  const outPath = path.join(outDir, fileName);
698
747
  packToFile(abs, outPath);
699
748
  const sha256 = sha256File(outPath);
749
+ const assetDigest = `sha256:${sha256}`;
700
750
  const size = fs.statSync(outPath).size;
701
751
  console.log(` ✓ Packed: ${outPath} (${size} bytes)`);
702
- console.log(` ✓ sha256: ${sha256}`);
752
+ console.log(` ✓ asset_digest: ${assetDigest}`);
703
753
 
704
754
  // 4. Optional upload via gh CLI
705
755
  const tagIdx = args.indexOf('--release-tag');
@@ -726,8 +776,9 @@ function cmdPublish(domainPath, args = []) {
726
776
  name,
727
777
  type: manifest.cluster ? 'cluster' : 'domain',
728
778
  version: manifest.version,
729
- kdna_url: kdnaUrl,
730
- sha256,
779
+ asset_url: kdnaUrl,
780
+ asset_digest: assetDigest,
781
+ content_digest: manifest.content_digest || null,
731
782
  signature: manifest.signature,
732
783
  release_status: kdnaUrl ? 'published_signed' : 'published_signed_local',
733
784
  author: { ...manifest.author },
package/src/registry.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * RegistryResolver — KDNA v0.7 registry client.
2
+ * RegistryResolver — KDNA asset-first registry client.
3
3
  *
4
4
  * Responsibilities:
5
5
  * 1. Resolve names: bare → @aikdna/bare, validate @scope/name format
@@ -7,7 +7,7 @@
7
7
  * 3. Cache registry metadata locally
8
8
  * 4. Surface scope trust info to install/publish
9
9
  *
10
- * Schema v2.0 — see kdna-registry/SCHEMA.md
10
+ * Schema v3.0 — see kdna-registry/SCHEMA.md
11
11
  */
12
12
 
13
13
  const fs = require('fs');
@@ -22,6 +22,7 @@ const DEFAULT_OFFICIAL_SCOPE = '@aikdna';
22
22
  const CANONICAL_REGISTRY_URL =
23
23
  process.env.KDNA_REGISTRY_URL ||
24
24
  'https://raw.githubusercontent.com/aikdna/kdna-registry/main/domains.json';
25
+ const REQUIRED_SCHEMA_VERSION = '3.0';
25
26
 
26
27
  const NAME_RE = /^@([a-z][a-z0-9-]*)\/([a-z][a-z0-9_]*)$/;
27
28
  const BARE_NAME_RE = /^[a-z][a-z0-9_]*$/;
@@ -39,6 +40,60 @@ function writeJson(file, data) {
39
40
  fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
40
41
  }
41
42
 
43
+ function parseDate(value) {
44
+ const date = value ? new Date(value) : null;
45
+ return date && !Number.isNaN(date.getTime()) ? date : null;
46
+ }
47
+
48
+ function registryTrustIssues(registry, { now = new Date() } = {}) {
49
+ const issues = [];
50
+ const trust = registry?.trust || {};
51
+
52
+ if (!registry || registry.schema_version !== REQUIRED_SCHEMA_VERSION) {
53
+ issues.push(
54
+ `Registry schema_version must be ${REQUIRED_SCHEMA_VERSION}, got ${JSON.stringify(registry?.schema_version)}`,
55
+ );
56
+ }
57
+
58
+ if (!trust.model) issues.push('registry.trust.model is required');
59
+ if (!trust.snapshot) issues.push('registry.trust.snapshot is required');
60
+ if (!trust.timestamp) issues.push('registry.trust.timestamp is required');
61
+
62
+ const snapshotVersion = trust.snapshot?.registry_version;
63
+ if (snapshotVersion && snapshotVersion !== registry.registry_version) {
64
+ issues.push(
65
+ `registry.trust.snapshot.registry_version ${snapshotVersion} does not match registry_version ${registry.registry_version}`,
66
+ );
67
+ }
68
+
69
+ const snapshotExpires = parseDate(trust.snapshot?.expires_at);
70
+ const timestampExpires = parseDate(trust.timestamp?.expires_at);
71
+ if (!snapshotExpires) issues.push('registry.trust.snapshot.expires_at must be an ISO timestamp');
72
+ if (!timestampExpires) issues.push('registry.trust.timestamp.expires_at must be an ISO timestamp');
73
+ if (snapshotExpires && snapshotExpires <= now) {
74
+ issues.push(`registry snapshot expired at ${trust.snapshot.expires_at}`);
75
+ }
76
+ if (timestampExpires && timestampExpires <= now) {
77
+ issues.push(`registry timestamp expired at ${trust.timestamp.expires_at}`);
78
+ }
79
+
80
+ return issues;
81
+ }
82
+
83
+ function registryRevocations(registry) {
84
+ return registry?.trust?.revocations || [];
85
+ }
86
+
87
+ function isEntryRevoked(registry, entry) {
88
+ const revocations = registryRevocations(registry);
89
+ return revocations.find((rev) => {
90
+ if (rev.name && rev.name !== entry.name) return false;
91
+ if (rev.version && rev.version !== entry.version) return false;
92
+ if (rev.asset_digest && rev.asset_digest !== entry.asset_digest) return false;
93
+ return rev.name || rev.asset_digest;
94
+ }) || null;
95
+ }
96
+
42
97
  // ─── Name parsing ───────────────────────────────────────────────────────
43
98
 
44
99
  /**
@@ -151,7 +206,15 @@ class RegistryResolver {
151
206
  _loadRegistryForScope(scopeName) {
152
207
  if (this._registries.has(scopeName)) return this._registries.get(scopeName);
153
208
  const source = this._sourceForScope(scopeName);
154
- const data = source.load({ allowNetwork: this.allowNetwork, refresh: this.refresh });
209
+ let data = source.load({ allowNetwork: this.allowNetwork, refresh: this.refresh });
210
+ let trustIssues = data ? registryTrustIssues(data) : [];
211
+ if (trustIssues.length && this.allowNetwork && !this.refresh) {
212
+ data = source.load({ allowNetwork: true, refresh: true });
213
+ trustIssues = data ? registryTrustIssues(data) : [];
214
+ }
215
+ if (trustIssues.length) {
216
+ throw new Error(`Registry trust check failed:\n${trustIssues.map((i) => `- ${i}`).join('\n')}`);
217
+ }
155
218
  this._registries.set(scopeName, data);
156
219
  return data;
157
220
  }
@@ -186,12 +249,6 @@ class RegistryResolver {
186
249
  );
187
250
  }
188
251
 
189
- if (registry.schema_version && registry.schema_version !== '2.0') {
190
- throw new Error(
191
- `Registry schema_version ${registry.schema_version} not supported. This CLI requires 2.0.`,
192
- );
193
- }
194
-
195
252
  const scope = registry.scopes?.[parsed.scope];
196
253
  if (!scope) {
197
254
  throw new Error(`Scope ${parsed.scope} not registered in registry.`);
@@ -215,6 +272,13 @@ class RegistryResolver {
215
272
  throw new Error(`${entry.name}@${entry.version} has been yanked${when}.${reason}${replace}`);
216
273
  }
217
274
 
275
+ const revocation = isEntryRevoked(registry, entry);
276
+ if (revocation) {
277
+ const reason = revocation.reason ? `\nReason: ${revocation.reason}` : '';
278
+ const when = revocation.revoked_at ? ` (revoked ${revocation.revoked_at.slice(0, 10)})` : '';
279
+ throw new Error(`${entry.name}@${entry.version} has been revoked${when}.${reason}`);
280
+ }
281
+
218
282
  return { parsed, scope, entry, registry };
219
283
  }
220
284
 
@@ -250,6 +314,9 @@ function fetchRegistry() {
250
314
  module.exports = {
251
315
  RegistryResolver,
252
316
  parseName,
317
+ REQUIRED_SCHEMA_VERSION,
318
+ registryTrustIssues,
319
+ isEntryRevoked,
253
320
  loadRegistry,
254
321
  fetchRegistry,
255
322
  CANONICAL_REGISTRY_URL,