@ijfw/memory-server 1.4.0 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,19 @@ import {
29
29
  addTrustedPublisher,
30
30
  removeTrustedPublisher,
31
31
  readTrustedPublishers,
32
+ loadPublisherKeypair,
33
+ signRotationToken,
34
+ verifyRotationToken,
32
35
  } from '../extension-signer.js';
36
+ import {
37
+ refreshTrustFromRegistry,
38
+ readCachedRegistry,
39
+ verifyRegistry,
40
+ keygenMeta,
41
+ signRegistry,
42
+ verifyRegistryFile,
43
+ DEFAULT_REGISTRY_URL,
44
+ } from '../extension-registry.js';
33
45
  import {
34
46
  deployExtensionSkillsToPlatforms,
35
47
  deployExtensionToAgentsMd,
@@ -355,6 +367,220 @@ async function deployOneExtension({ scope, name, extDir, projectRoot, result })
355
367
  }
356
368
  }
357
369
 
370
+ // === B6: Registry commands ================================================
371
+
372
+ async function cmdTrustRegistry({ args }) {
373
+ const url = args.trim() || DEFAULT_REGISTRY_URL;
374
+ try {
375
+ const r = await refreshTrustFromRegistry(url);
376
+ if (!r.ok) return { ok: false, command: 'trust-registry', error: r.error };
377
+ const lines = [];
378
+ if (r.fromCache) {
379
+ lines.push('(loaded from cache — network unavailable)');
380
+ } else if (r.diff) {
381
+ for (const kid of r.diff.added) lines.push(`+ added: ${kid}`);
382
+ for (const kid of r.diff.removed) lines.push(`- removed (revoked): ${kid}`);
383
+ for (const kid of r.diff.unchanged) lines.push(` unchanged: ${kid}`);
384
+ for (const kid of r.diff.rejected) lines.push(`! rejected: ${kid}`);
385
+ if (lines.length === 0) lines.push('registry applied — no changes');
386
+ }
387
+ for (const w of (r.warnings || [])) lines.push(`[warn] ${w}`);
388
+ return { ok: true, command: 'trust-registry', result: { url, diff: r.diff, fromCache: r.fromCache, lines } };
389
+ } catch (err) {
390
+ return { ok: false, command: 'trust-registry', error: err.message };
391
+ }
392
+ }
393
+
394
+ async function cmdRegistryStatus() {
395
+ try {
396
+ const cached = await readCachedRegistry();
397
+ if (!cached.registry) {
398
+ return { ok: true, command: 'registry-status', result: { cached: false, message: 'no cached registry' } };
399
+ }
400
+ const ageMs = cached.cachedAt ? Date.now() - cached.cachedAt : null;
401
+ const ageHours = ageMs !== null ? (ageMs / 3600000).toFixed(1) : null;
402
+ const body = JSON.stringify(cached.registry);
403
+ const sizeBytes = Buffer.byteLength(body, 'utf8');
404
+ const sigStatus = verifyRegistry(body);
405
+ return {
406
+ ok: true,
407
+ command: 'registry-status',
408
+ result: {
409
+ cached: true,
410
+ stale: cached.stale,
411
+ cached_at: cached.cachedAt ? new Date(cached.cachedAt).toISOString() : null,
412
+ age_hours: ageHours,
413
+ size_bytes: sizeBytes,
414
+ signature_valid: sigStatus.valid,
415
+ signature_reason: sigStatus.reason,
416
+ publisher_count: Object.keys(cached.registry.publishers || {}).length,
417
+ revoked_count: (cached.registry.revoked || []).length,
418
+ },
419
+ };
420
+ } catch (err) {
421
+ return { ok: false, command: 'registry-status', error: err.message };
422
+ }
423
+ }
424
+
425
+ async function cmdKeygenMeta({ args }) {
426
+ const author = args.trim();
427
+ if (!author) return { ok: false, command: 'keygen-meta', error: 'missing author name; usage: keygen-meta <author>' };
428
+ try {
429
+ const r = await keygenMeta(author);
430
+ return {
431
+ ok: true,
432
+ command: 'keygen-meta',
433
+ result: { keyId: r.keyId, publicKey: r.publicKey, dir: r.dir },
434
+ };
435
+ } catch (err) {
436
+ return { ok: false, command: 'keygen-meta', error: err.message };
437
+ }
438
+ }
439
+
440
+ async function cmdSignRegistry({ args }) {
441
+ const registryPath = args.trim();
442
+ if (!registryPath) return { ok: false, command: 'sign-registry', error: 'missing path; usage: sign-registry <path>' };
443
+ try {
444
+ const r = await signRegistry(registryPath);
445
+ return r.ok
446
+ ? { ok: true, command: 'sign-registry', result: { path: registryPath } }
447
+ : { ok: false, command: 'sign-registry', error: r.error };
448
+ } catch (err) {
449
+ return { ok: false, command: 'sign-registry', error: err.message };
450
+ }
451
+ }
452
+
453
+ async function cmdVerifyRegistry({ args }) {
454
+ const registryPath = args.trim();
455
+ if (!registryPath) return { ok: false, command: 'verify-registry', error: 'missing path; usage: verify-registry <path>' };
456
+ try {
457
+ const r = await verifyRegistryFile(registryPath);
458
+ return {
459
+ ok: r.ok,
460
+ command: 'verify-registry',
461
+ result: { path: registryPath, valid: r.valid, reason: r.reason },
462
+ };
463
+ } catch (err) {
464
+ return { ok: false, command: 'verify-registry', error: err.message };
465
+ }
466
+ }
467
+
468
+ // === B8: Key rotation + revocation =========================================
469
+
470
+ /**
471
+ * rotate-keys <oldKeyId> <newKeyId> [--out <file>]
472
+ *
473
+ * Loads both keypairs from ~/.ijfw/keys/<keyId>/, produces a rotation token
474
+ * signed by the old private key, writes JSON to --out or stdout.
475
+ */
476
+ async function cmdRotateKeys({ args }) {
477
+ const tokens = String(args || '').split(/\s+/).filter(Boolean);
478
+
479
+ // Extract --out flag
480
+ let outFile = null;
481
+ const keep = [];
482
+ for (let i = 0; i < tokens.length; i++) {
483
+ if (tokens[i] === '--out' && tokens[i + 1]) {
484
+ outFile = tokens[i + 1];
485
+ i++;
486
+ } else {
487
+ keep.push(tokens[i]);
488
+ }
489
+ }
490
+
491
+ const [oldKeyId, newKeyId] = keep;
492
+ if (!oldKeyId || !newKeyId) {
493
+ return { ok: false, command: 'rotate-keys', error: 'usage: rotate-keys <oldKeyId> <newKeyId> [--out <file>]' };
494
+ }
495
+
496
+ const oldKp = await loadPublisherKeypair(oldKeyId);
497
+ if (!oldKp) return { ok: false, command: 'rotate-keys', error: `old keypair not found: ${oldKeyId}` };
498
+
499
+ const newKp = await loadPublisherKeypair(newKeyId);
500
+ if (!newKp) return { ok: false, command: 'rotate-keys', error: `new keypair not found: ${newKeyId}` };
501
+
502
+ let token;
503
+ try {
504
+ token = signRotationToken(oldKp.privateKey, newKp.publicKey);
505
+ } catch (err) {
506
+ return { ok: false, command: 'rotate-keys', error: `sign failed: ${err.message}` };
507
+ }
508
+
509
+ const json = JSON.stringify(token, null, 2) + '\n';
510
+
511
+ if (outFile) {
512
+ try {
513
+ await fs.writeFile(path.resolve(outFile), json, 'utf8');
514
+ } catch (err) {
515
+ return { ok: false, command: 'rotate-keys', error: `write failed: ${err.message}` };
516
+ }
517
+ return { ok: true, command: 'rotate-keys', result: { token, out: path.resolve(outFile) } };
518
+ }
519
+
520
+ return { ok: true, command: 'rotate-keys', result: { token } };
521
+ }
522
+
523
+ /**
524
+ * verify-rotation-token <file>
525
+ *
526
+ * Reads a rotation token JSON, looks up the old public key from the local
527
+ * trusted-publishers store (or ~/.ijfw/keys/<oldKeyId>/public.pem as fallback),
528
+ * calls verifyRotationToken, prints verdict.
529
+ */
530
+ async function cmdVerifyRotationToken({ args }) {
531
+ const filePath = String(args || '').trim();
532
+ if (!filePath) {
533
+ return { ok: false, command: 'verify-rotation-token', error: 'usage: verify-rotation-token <file>' };
534
+ }
535
+
536
+ let raw;
537
+ try {
538
+ raw = await fs.readFile(path.resolve(filePath), 'utf8');
539
+ } catch (err) {
540
+ return { ok: false, command: 'verify-rotation-token', error: `read failed: ${err.message}` };
541
+ }
542
+
543
+ let token;
544
+ try {
545
+ token = JSON.parse(raw);
546
+ } catch (err) {
547
+ return { ok: false, command: 'verify-rotation-token', error: `JSON parse failed: ${err.message}` };
548
+ }
549
+
550
+ const oldKeyId = token && token.old_key_id;
551
+ if (!oldKeyId) {
552
+ return { ok: false, command: 'verify-rotation-token', error: 'token missing old_key_id' };
553
+ }
554
+
555
+ // Look up old public key: trusted-publishers store first, then key-dir fallback.
556
+ let oldPublicKey = null;
557
+ const store = await readTrustedPublishers();
558
+ const entry = store.publishers && store.publishers[oldKeyId];
559
+ if (entry && entry.publicKey) {
560
+ oldPublicKey = entry.publicKey;
561
+ } else {
562
+ // Fallback: key may be on disk even if not in trust store (already revoked).
563
+ const kp = await loadPublisherKeypair(oldKeyId);
564
+ if (kp) oldPublicKey = kp.publicKey;
565
+ }
566
+
567
+ if (!oldPublicKey) {
568
+ return { ok: false, command: 'verify-rotation-token', error: `old public key not found for keyId: ${oldKeyId}` };
569
+ }
570
+
571
+ const verdict = verifyRotationToken(token, oldPublicKey);
572
+ return {
573
+ ok: verdict.valid,
574
+ command: 'verify-rotation-token',
575
+ result: {
576
+ valid: verdict.valid,
577
+ reason: verdict.reason,
578
+ old_key_id: token.old_key_id,
579
+ new_key_id: token.new_key_id,
580
+ },
581
+ };
582
+ }
583
+
358
584
  async function cmdActivate({ args, projectRoot }) {
359
585
  const name = args && args.trim();
360
586
  if (!name) return { ok: false, command: 'activate', error: 'missing extension name; usage: activate <name>' };
@@ -380,8 +606,46 @@ async function cmdDeactivate() {
380
606
  }
381
607
  }
382
608
 
609
+ // v1.4.3 Phase D — CLI module registry. Each module exports the frozen
610
+ // { handlers, subcommandHelp } shape; we union their handlers into a single
611
+ // lookup and consult it BEFORE the legacy switch, so new --ide / --backend /
612
+ // quota / federation subcommands take precedence.
613
+ let _v143Handlers = null;
614
+ async function loadV143Handlers() {
615
+ if (_v143Handlers !== null) return _v143Handlers;
616
+ const [registry, signer, quota, active] = await Promise.all([
617
+ import('./registry-cli.js'),
618
+ import('./signer-cli.js'),
619
+ import('./quota-cli.js'),
620
+ import('./active-cli.js'),
621
+ ]);
622
+ _v143Handlers = Object.assign(
623
+ Object.create(null),
624
+ registry.handlers || {},
625
+ signer.handlers || {},
626
+ quota.handlers || {},
627
+ active.handlers || {},
628
+ );
629
+ return _v143Handlers;
630
+ }
631
+
383
632
  export async function extensionDispatch({ command, args = '', projectRoot }) {
384
633
  const ctx = { command, args: String(args || ''), projectRoot: String(projectRoot || process.cwd()) };
634
+
635
+ // v1.4.3 Phase D — CLI modules take precedence over legacy switch.
636
+ const handlers = await loadV143Handlers();
637
+ if (typeof handlers[command] === 'function') {
638
+ try {
639
+ const r = await handlers[command](ctx.args, ctx);
640
+ if (r && r.ok === false) {
641
+ return { ok: false, command, error: r.error || 'unknown error' };
642
+ }
643
+ return { ok: true, command, result: r };
644
+ } catch (err) {
645
+ return { ok: false, command, error: err && err.message ? err.message : String(err) };
646
+ }
647
+ }
648
+
385
649
  switch (command) {
386
650
  case 'add': return cmdAdd(ctx);
387
651
  case 'list': return cmdList(ctx);
@@ -394,11 +658,18 @@ export async function extensionDispatch({ command, args = '', projectRoot }) {
394
658
  case 'trusted': return cmdTrusted(ctx);
395
659
  case 'activate': return cmdActivate(ctx);
396
660
  case 'deactivate': return cmdDeactivate(ctx);
661
+ case 'trust-registry': return cmdTrustRegistry(ctx);
662
+ case 'registry-status': return cmdRegistryStatus(ctx);
663
+ case 'keygen-meta': return cmdKeygenMeta(ctx);
664
+ case 'sign-registry': return cmdSignRegistry(ctx);
665
+ case 'verify-registry': return cmdVerifyRegistry(ctx);
666
+ case 'rotate-keys': return cmdRotateKeys(ctx);
667
+ case 'verify-rotation-token': return cmdVerifyRotationToken(ctx);
397
668
  default:
398
669
  return {
399
670
  ok: false,
400
671
  command,
401
- error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate`,
672
+ error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate | trust-registry | registry-status | keygen-meta | sign-registry | verify-registry | rotate-keys | verify-rotation-token`,
402
673
  };
403
674
  }
404
675
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * dispatch/quota-cli.js — IJFW v1.4.3 W9-A3 (B16)
3
+ *
4
+ * Frozen CLI module contract:
5
+ * export const handlers — { '<subcommand>': async (args, ctx) => { ok, output?, error? } }
6
+ * export const subcommandHelp — { '<subcommand>': 'one-line description' }
7
+ *
8
+ * Phase D wires these into `dispatch/extension.js`'s main switch by iterating
9
+ * Object.entries(handlers). Until then, this module is callable in isolation.
10
+ */
11
+
12
+ import { getQuotaUsage, resetExtensionQuotas } from '../extension-quota-tracker.js';
13
+
14
+ function homeFromCtx(ctx) {
15
+ if (ctx && typeof ctx.homedir === 'string') return ctx.homedir;
16
+ if (ctx && typeof ctx.homeDir === 'string') return ctx.homeDir;
17
+ return undefined; // tracker will fall back to env HOME / homedir()
18
+ }
19
+
20
+ export const handlers = Object.freeze({
21
+ 'quota-status': async (args, ctx) => {
22
+ const name = Array.isArray(args) ? args[0] : undefined;
23
+ if (!name || typeof name !== 'string') {
24
+ return { ok: false, error: 'quota-status: extension name required' };
25
+ }
26
+ const usage = await getQuotaUsage(name, { homeDir: homeFromCtx(ctx) });
27
+ return { ok: true, output: JSON.stringify(usage, null, 2) };
28
+ },
29
+ 'quota-reset': async (args, ctx) => {
30
+ const name = Array.isArray(args) ? args[0] : undefined;
31
+ if (!name || typeof name !== 'string') {
32
+ return { ok: false, error: 'quota-reset: extension name required' };
33
+ }
34
+ await resetExtensionQuotas(name, { homeDir: homeFromCtx(ctx) });
35
+ return { ok: true, output: 'reset' };
36
+ },
37
+ });
38
+
39
+ export const subcommandHelp = Object.freeze({
40
+ 'quota-status': 'quota-status [<ext-name>] — print current usage vs limits',
41
+ 'quota-reset': 'quota-reset <ext-name> — admin: manually reset counters',
42
+ });
@@ -0,0 +1,339 @@
1
+ /**
2
+ * dispatch/registry-cli.js — IJFW v1.4.3/B14 + B17 registry CLI handlers.
3
+ *
4
+ * Frozen export contract (Wave A invariants):
5
+ * export const handlers = {
6
+ * '<subcommand>': async (args, ctx) => ({ ok, output?, error? }),
7
+ * ...
8
+ * };
9
+ * export const subcommandHelp = {
10
+ * '<subcommand>': 'one-line description',
11
+ * ...
12
+ * };
13
+ *
14
+ * Subcommands owned by this module:
15
+ * - registry-list
16
+ * - registry-add <name> <url> [<meta-key-path>]
17
+ * - registry-remove <name>
18
+ * - registry-prioritize <name> <position>
19
+ * - registry-status
20
+ * - trust-registry --emergency [<url>]
21
+ *
22
+ * Phase D will wire these into dispatch/extension.js via
23
+ * `Object.entries(handlers)`. Each handler accepts a token array (already
24
+ * tokenized + dequoted) and the dispatch ctx (cwd, homedir, etc.).
25
+ */
26
+
27
+ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
28
+ import { join } from 'node:path';
29
+ import { homedir as osHomedir } from 'node:os';
30
+ import { createPublicKey } from 'node:crypto';
31
+
32
+ import {
33
+ loadRegistrySources,
34
+ refreshTrustFromAllRegistries,
35
+ readSourceCache,
36
+ RegistrySourcesError,
37
+ META_KEY_SENTINEL,
38
+ IJFW_REGISTRY_META_KEY_PEM,
39
+ SOURCE_NAME_PATTERN,
40
+ } from '../extension-registry.js';
41
+
42
+ function homedir(ctx) {
43
+ return (ctx && ctx.homedir) || osHomedir();
44
+ }
45
+
46
+ function registriesConfigPath(ctx) {
47
+ return join(homedir(ctx), '.ijfw', 'registries.json');
48
+ }
49
+
50
+ async function readRegistriesFile(ctx) {
51
+ const path = registriesConfigPath(ctx);
52
+ try {
53
+ const raw = await readFile(path, 'utf8');
54
+ const parsed = JSON.parse(raw);
55
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.registries)) {
56
+ throw new Error('registries.json: invalid shape');
57
+ }
58
+ return { path, doc: parsed };
59
+ } catch (err) {
60
+ if (err && err.code === 'ENOENT') {
61
+ return {
62
+ path,
63
+ doc: { schema_version: '1.0', registries: [] },
64
+ };
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+
70
+ async function writeRegistriesFile(ctx, doc) {
71
+ const path = registriesConfigPath(ctx);
72
+ await mkdir(join(homedir(ctx), '.ijfw'), { recursive: true });
73
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
74
+ await writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
75
+ const { rename } = await import('node:fs/promises');
76
+ await rename(tmp, path);
77
+ }
78
+
79
+ function tokenize(args) {
80
+ if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
81
+ const s = String(args || '').trim();
82
+ if (!s) return [];
83
+ // Simple whitespace split for CLI surface.
84
+ return s.split(/\s+/);
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Handlers
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * registry-list — print all configured registries in priority order.
93
+ */
94
+ async function registryList(_args, _ctx) {
95
+ let sources;
96
+ try {
97
+ sources = await loadRegistrySources();
98
+ } catch (err) {
99
+ if (err instanceof RegistrySourcesError) {
100
+ return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
101
+ }
102
+ throw err;
103
+ }
104
+ const lines = ['IJFW Registry Sources (priority order):', ''];
105
+ for (const src of sources) {
106
+ const usingEmbedded =
107
+ src.meta_key_pem === IJFW_REGISTRY_META_KEY_PEM ? ' [meta_key=<embedded>]' : '';
108
+ lines.push(` [${src.priority}] ${src.name}`);
109
+ lines.push(` url: ${src.url}`);
110
+ lines.push(
111
+ ` ttl: publishers=${Math.round(src.publisher_ttl_ms / 1000)}s revocation=${Math.round(src.revocation_ttl_ms / 1000)}s${usingEmbedded}`,
112
+ );
113
+ }
114
+ return { ok: true, output: lines.join('\n') };
115
+ }
116
+
117
+ /**
118
+ * registry-add <name> <url> [<meta-key-path>]
119
+ *
120
+ * Appends to ~/.ijfw/registries.json. Validates name/url/PEM. Refuses
121
+ * duplicate names. Persists in priority-append order.
122
+ */
123
+ async function registryAdd(args, ctx) {
124
+ const tokens = tokenize(args);
125
+ if (tokens.length < 2) {
126
+ return { ok: false, error: 'usage: registry-add <name> <url> [<meta-key-path>]' };
127
+ }
128
+ const [name, url, metaKeyPath] = tokens;
129
+
130
+ if (!SOURCE_NAME_PATTERN.test(name)) {
131
+ return { ok: false, error: `name must match /^[a-z0-9_-]+$/, got '${name}'` };
132
+ }
133
+ let parsedUrl;
134
+ try {
135
+ parsedUrl = new URL(url);
136
+ } catch {
137
+ return { ok: false, error: `invalid URL: ${url}` };
138
+ }
139
+ if (parsedUrl.protocol !== 'https:') {
140
+ return { ok: false, error: `url must use HTTPS, got ${parsedUrl.protocol}` };
141
+ }
142
+
143
+ let metaKeyPem = META_KEY_SENTINEL;
144
+ if (metaKeyPath) {
145
+ let raw;
146
+ try {
147
+ raw = await readFile(metaKeyPath, 'utf8');
148
+ } catch (err) {
149
+ return { ok: false, error: `cannot read meta-key at ${metaKeyPath}: ${err.message}` };
150
+ }
151
+ try {
152
+ createPublicKey(raw);
153
+ } catch (err) {
154
+ return { ok: false, error: `meta-key PEM parse failed: ${err.message}` };
155
+ }
156
+ metaKeyPem = raw;
157
+ }
158
+
159
+ const { doc } = await readRegistriesFile(ctx);
160
+ if (doc.registries.some((r) => r.name === name)) {
161
+ return { ok: false, error: `registry '${name}' already exists` };
162
+ }
163
+ const nextPriority =
164
+ doc.registries.reduce((max, r) => (typeof r.priority === 'number' ? Math.max(max, r.priority) : max), -1) + 1;
165
+ doc.registries.push({
166
+ name,
167
+ url,
168
+ meta_key_pem: metaKeyPem,
169
+ priority: nextPriority,
170
+ publisher_ttl_ms: 24 * 60 * 60 * 1000,
171
+ revocation_ttl_ms: 5 * 60 * 1000,
172
+ });
173
+ await writeRegistriesFile(ctx, doc);
174
+ return { ok: true, output: `added registry '${name}' at priority ${nextPriority}` };
175
+ }
176
+
177
+ /**
178
+ * registry-remove <name>
179
+ */
180
+ async function registryRemove(args, ctx) {
181
+ const tokens = tokenize(args);
182
+ if (tokens.length < 1) {
183
+ return { ok: false, error: 'usage: registry-remove <name>' };
184
+ }
185
+ const name = tokens[0];
186
+ const { doc } = await readRegistriesFile(ctx);
187
+ const idx = doc.registries.findIndex((r) => r.name === name);
188
+ if (idx === -1) {
189
+ return { ok: false, error: `registry '${name}' not found` };
190
+ }
191
+ doc.registries.splice(idx, 1);
192
+ await writeRegistriesFile(ctx, doc);
193
+ return { ok: true, output: `removed registry '${name}'` };
194
+ }
195
+
196
+ /**
197
+ * registry-prioritize <name> <position>
198
+ */
199
+ async function registryPrioritize(args, ctx) {
200
+ const tokens = tokenize(args);
201
+ if (tokens.length < 2) {
202
+ return { ok: false, error: 'usage: registry-prioritize <name> <position>' };
203
+ }
204
+ const [name, posRaw] = tokens;
205
+ const position = Number.parseInt(posRaw, 10);
206
+ if (!Number.isFinite(position)) {
207
+ return { ok: false, error: `position must be a non-negative integer, got '${posRaw}'` };
208
+ }
209
+ const { doc } = await readRegistriesFile(ctx);
210
+ const target = doc.registries.find((r) => r.name === name);
211
+ if (!target) {
212
+ return { ok: false, error: `registry '${name}' not found` };
213
+ }
214
+ target.priority = position;
215
+ doc.registries.sort((a, b) => (a.priority || 0) - (b.priority || 0));
216
+ // Renumber to ensure unique priorities.
217
+ doc.registries.forEach((r, i) => {
218
+ r.priority = i;
219
+ });
220
+ await writeRegistriesFile(ctx, doc);
221
+ return { ok: true, output: `set '${name}' to priority ${position} (renumbered)` };
222
+ }
223
+
224
+ /**
225
+ * registry-status — per-source cache age + fetch state.
226
+ */
227
+ async function registryStatus(_args, _ctx) {
228
+ let sources;
229
+ try {
230
+ sources = await loadRegistrySources();
231
+ } catch (err) {
232
+ if (err instanceof RegistrySourcesError) {
233
+ return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
234
+ }
235
+ throw err;
236
+ }
237
+ const lines = ['IJFW Registry Status:', ''];
238
+ for (const src of sources) {
239
+ const { cache, corrupt, reason } = await readSourceCache(src);
240
+ lines.push(` [${src.priority}] ${src.name} (${src.url})`);
241
+ if (corrupt) {
242
+ lines.push(` CORRUPT (${reason}) — next refresh will rebuild`);
243
+ continue;
244
+ }
245
+ const pubAt = cache.publishers_fetched_at || '(never)';
246
+ const revAt = cache.revocation_fetched_at || '(never)';
247
+ const pubCount = cache.publishers ? Object.keys(cache.publishers).length : 0;
248
+ const revCount = Array.isArray(cache.revoked) ? cache.revoked.length : 0;
249
+ lines.push(` publishers_fetched_at: ${pubAt} (${pubCount} entries)`);
250
+ lines.push(` revocation_fetched_at: ${revAt} (${revCount} revoked)`);
251
+ }
252
+ return { ok: true, output: lines.join('\n') };
253
+ }
254
+
255
+ /**
256
+ * trust-registry --emergency [<url>] — bypass caches; force fresh fetch.
257
+ *
258
+ * Without --emergency, fall through to the regular split-TTL path.
259
+ */
260
+ async function trustRegistry(args, _ctx) {
261
+ const tokens = tokenize(args);
262
+ const emergency = tokens.includes('--emergency');
263
+ const remaining = tokens.filter((t) => t !== '--emergency');
264
+
265
+ // Optional URL arg post-emergency. With a URL we target only that single
266
+ // source via loadRegistrySources()'s back-compat fall-back. Without, full
267
+ // federation refresh.
268
+ const url = remaining[0];
269
+
270
+ const opts = { emergency };
271
+ if (url) {
272
+ // Synthesize a one-shot source descriptor.
273
+ opts.sources = [
274
+ {
275
+ name: 'cli',
276
+ url,
277
+ meta_key_pem: IJFW_REGISTRY_META_KEY_PEM,
278
+ priority: 0,
279
+ publisher_ttl_ms: 24 * 60 * 60 * 1000,
280
+ revocation_ttl_ms: 5 * 60 * 1000,
281
+ },
282
+ ];
283
+ }
284
+
285
+ let result;
286
+ try {
287
+ result = await refreshTrustFromAllRegistries(opts);
288
+ } catch (err) {
289
+ if (err instanceof RegistrySourcesError) {
290
+ return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
291
+ }
292
+ return { ok: false, error: err.message };
293
+ }
294
+ if (!result.ok) {
295
+ return { ok: false, error: result.error || 'refresh failed' };
296
+ }
297
+ const summary = result.multi
298
+ ? `sources=${result.multi.sources.length} global_revocations=${result.multi.global_revocations.length} conflicts=${result.multi.conflicts.length}`
299
+ : 'no diff';
300
+ const warnings = result.warnings && result.warnings.length > 0
301
+ ? `\nwarnings:\n${result.warnings.map((w) => ` - ${w}`).join('\n')}`
302
+ : '';
303
+ return {
304
+ ok: true,
305
+ output: `trust-registry${emergency ? ' --emergency' : ''}: ${summary}${warnings}`,
306
+ };
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Frozen exports (Wave-A invariant)
311
+ // ---------------------------------------------------------------------------
312
+
313
+ export const handlers = Object.freeze({
314
+ 'registry-list': registryList,
315
+ 'registry-add': registryAdd,
316
+ 'registry-remove': registryRemove,
317
+ 'registry-prioritize': registryPrioritize,
318
+ 'registry-status': registryStatus,
319
+ 'trust-registry': trustRegistry,
320
+ });
321
+
322
+ export const subcommandHelp = Object.freeze({
323
+ 'registry-list': 'list configured registries in priority order',
324
+ 'registry-add': 'add <name> <url> [<meta-key-path>] — append a registry source',
325
+ 'registry-remove': 'remove <name> — remove a registry source by name',
326
+ 'registry-prioritize': 'prioritize <name> <position> — change a source\'s priority',
327
+ 'registry-status': 'show per-source cache state (publishers + revocation TTLs)',
328
+ 'trust-registry': 'refresh trust from all sources; --emergency bypasses cache',
329
+ });
330
+
331
+ // Helper for tests
332
+ export const _testInternals = Object.freeze({
333
+ registriesConfigPath,
334
+ readRegistriesFile,
335
+ writeRegistriesFile,
336
+ });
337
+
338
+ // Avoid an "unused" lint hit on `stat` (kept for future use).
339
+ void stat;