@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.
- package/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
|
@@ -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;
|