@ijfw/memory-server 1.4.1 → 1.4.4

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.
@@ -1,19 +1,33 @@
1
1
  /**
2
- * extension-registry.js — IJFW v1.4.1/B6 Hosted Publisher Key Registry.
2
+ * extension-registry.js — IJFW v1.4.1/B6 Hosted Publisher Key Registry
3
+ * + IJFW v1.4.3/B14 Federated Registries
4
+ * + IJFW v1.4.3/B17 Live Revocation (split TTL + emergency)
3
5
  *
4
- * Fetches, verifies, and applies a canonical registry of trusted publishers
5
- * signed by the IJFW meta-key. Clients cache the registry locally with a
6
- * 24 h TTL; offline fallback returns the cached copy with a warning.
6
+ * v1.4.1 baseline: single hosted registry signed by the embedded meta-key.
7
+ *
8
+ * v1.4.3 lift (B14 + B17):
9
+ * - Federated registries: clients pull from a priority-ordered list defined in
10
+ * `~/.ijfw/registries.json`. Higher-priority publishers override lower; ANY
11
+ * source's revocation revokes globally (defense-in-depth).
12
+ * - Per-source cache files: `~/.ijfw/state/registry-cache-<sanitized-name>.json`.
13
+ * Atomic tmp+rename inside `withFsLock` (consumes W9-A0 fs-lock.js).
14
+ * - Split TTL: revocation polled every 5 min; publishers every 24 h.
15
+ * - Emergency refresh path bypasses all caches.
16
+ * - Cache corruption is reported per-source; never silently falls through.
17
+ * - `verifyRegistry(body, { metaKeyPem, allowSeed })` is the v1.4.3-frozen
18
+ * signature (SEC-L-01). Default meta key is the embedded `IJFW_REGISTRY_META_KEY_PEM`.
7
19
  *
8
20
  * Uses node:https + node:crypto + node:fs/promises only — zero new prod deps.
9
21
  */
10
22
 
11
23
  import { createPublicKey, createHash, verify as cryptoVerify } from 'node:crypto';
12
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
24
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
13
25
  import { homedir } from 'node:os';
14
- import { join } from 'node:path';
26
+ import { join, dirname } from 'node:path';
15
27
  import https from 'node:https';
16
28
 
29
+ import { withFsLock } from './fs-lock.js';
30
+
17
31
  // ---------------------------------------------------------------------------
18
32
  // Embedded meta-key — compiled-in trust root for registry signature verification.
19
33
  // Source: mcp-server/src/.registry-meta-key.pem (gitignored sentinel).
@@ -26,10 +40,16 @@ MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
26
40
  const DEFAULT_REGISTRY_URL = 'https://registry.ijfw.dev/publishers/v1.json';
27
41
  const FALLBACK_REGISTRY_URL = 'https://therealseandonahoe.gitlab.io/ijfw/registry/publishers/v1.json';
28
42
  const MAX_REGISTRY_BYTES = 1024 * 1024; // 1 MiB cap
29
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
43
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h (back-compat alias)
44
+ const PUBLISHER_TTL_MS = 24 * 60 * 60 * 1000; // 24 h — B17 publisher half
45
+ const REVOCATION_TTL_MS = 5 * 60 * 1000; // 5 min — B17 revocation half
30
46
  const FETCH_TIMEOUT_MS = 10_000;
31
47
  const MAX_REDIRECTS = 3;
32
48
 
49
+ // Source name shape (filesystem-safe; same vocab as gate_id).
50
+ const SOURCE_NAME_PATTERN = /^[a-z0-9_-]+$/;
51
+ const META_KEY_SENTINEL = '<embedded>';
52
+
33
53
  // ---------------------------------------------------------------------------
34
54
  // Paths
35
55
  // ---------------------------------------------------------------------------
@@ -46,6 +66,52 @@ function revokedPublishersPath() {
46
66
  return join(ijfwStateDir(), 'revoked-publishers.json');
47
67
  }
48
68
 
69
+ function registriesConfigPath() {
70
+ return join(homedir(), '.ijfw', 'registries.json');
71
+ }
72
+
73
+ // R12-H-02: serialise trust-store reads/writes. trusted-publishers.json +
74
+ // revoked-publishers.json are read, merged, then written; without a lock two
75
+ // concurrent `trust-registry --emergency` invocations interleave and the later
76
+ // writer drops the earlier writer's revocations.
77
+ function trustStoreLockPath() {
78
+ return join(ijfwStateDir(), 'trust-store.lock');
79
+ }
80
+
81
+ function sanitizeSourceName(name) {
82
+ if (typeof name !== 'string' || !SOURCE_NAME_PATTERN.test(name)) {
83
+ throw new RegistrySourcesError(
84
+ `source name must match /^[a-z0-9_-]+$/, got ${JSON.stringify(name)}`,
85
+ { reason: 'invalid_source_name' },
86
+ );
87
+ }
88
+ return name;
89
+ }
90
+
91
+ function perSourceCachePath(sourceName) {
92
+ return join(ijfwStateDir(), `registry-cache-${sanitizeSourceName(sourceName)}.json`);
93
+ }
94
+
95
+ function perSourceLockPath(sourceName) {
96
+ return join(ijfwStateDir(), `.registry-cache-${sanitizeSourceName(sourceName)}.lock`);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // RegistrySourcesError — thrown on container-malformed registries.json
101
+ // (SEC-M-01: container errors are fatal; remote-source failures are warnings).
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export class RegistrySourcesError extends Error {
105
+ constructor(message, info = {}) {
106
+ super(message);
107
+ this.name = 'RegistrySourcesError';
108
+ this.code = 'REGISTRY_SOURCES_ERROR';
109
+ this.reason = info.reason || 'malformed';
110
+ if (typeof info.line === 'number') this.line = info.line;
111
+ if (typeof info.column === 'number') this.column = info.column;
112
+ }
113
+ }
114
+
49
115
  // ---------------------------------------------------------------------------
50
116
  // Canonical signing bytes — same logic as extension-signer.js
51
117
  // Excludes `signature` from bytes so the field can carry the sig itself.
@@ -73,6 +139,11 @@ function registryCanonicalBytes(registry) {
73
139
  return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
74
140
  }
75
141
 
142
+ // Exposed for the WS client stub (same canonical-bytes algorithm).
143
+ export function _registryCanonicalBytesForTest(registry) {
144
+ return registryCanonicalBytes(registry);
145
+ }
146
+
76
147
  // ---------------------------------------------------------------------------
77
148
  // fetchRegistry — HTTPS-only, timeout + redirect cap + body size cap
78
149
  // ---------------------------------------------------------------------------
@@ -82,25 +153,41 @@ function registryCanonicalBytes(registry) {
82
153
  * @param {string} [url]
83
154
  * @param {object} [opts]
84
155
  * @param {Function} [opts.fetchImpl] - injectable for tests; receives (url) -> Promise<{ok,body,error}>
156
+ * @param {'all'|'revoked'} [opts.part] - B17: registry slice to fetch. Server returns
157
+ * the same JSON for both today; client caches them with their own TTL.
85
158
  * @returns {Promise<{ok: boolean, body: string|null, error: string|null}>}
86
159
  */
87
160
  export async function fetchRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
161
+ const part = opts.part === 'revoked' ? 'revoked' : 'all';
162
+ let fetchUrl = url;
163
+ if (part === 'revoked') {
164
+ // Append ?part=revoked (or &part=revoked) — the server ignores the query
165
+ // for v1.4.3 but CDN edge caches treat them as distinct cache keys.
166
+ try {
167
+ const u = new URL(url);
168
+ u.searchParams.set('part', 'revoked');
169
+ fetchUrl = u.toString();
170
+ } catch {
171
+ // Will be caught by HTTPS-only check below.
172
+ }
173
+ }
174
+
88
175
  if (typeof opts.fetchImpl === 'function') {
89
- return opts.fetchImpl(url);
176
+ return opts.fetchImpl(fetchUrl);
90
177
  }
91
178
 
92
179
  // HTTPS-only enforcement
93
180
  let parsedUrl;
94
181
  try {
95
- parsedUrl = new URL(url);
182
+ parsedUrl = new URL(fetchUrl);
96
183
  } catch {
97
- return { ok: false, body: null, error: `invalid URL: ${url}` };
184
+ return { ok: false, body: null, error: `invalid URL: ${fetchUrl}` };
98
185
  }
99
186
  if (parsedUrl.protocol !== 'https:') {
100
187
  return { ok: false, body: null, error: `registry URL must use HTTPS (got ${parsedUrl.protocol})` };
101
188
  }
102
189
 
103
- return _httpsGet(url, 0);
190
+ return _httpsGet(fetchUrl, 0);
104
191
  }
105
192
 
106
193
  function _httpsGet(url, redirectCount) {
@@ -170,17 +257,36 @@ function _httpsGet(url, redirectCount) {
170
257
  }
171
258
 
172
259
  // ---------------------------------------------------------------------------
173
- // verifyRegistry — parse JSON, validate shape, verify signature
260
+ // verifyRegistry — parse JSON, validate shape, verify signature.
261
+ //
262
+ // v1.4.3 (SEC-L-01): standardized call signature
263
+ // verifyRegistry(body, { metaKeyPem, allowSeed })
264
+ // metaKeyPem defaults to the embedded IJFW_REGISTRY_META_KEY_PEM so v1.4.1
265
+ // callers and existing tests keep working. allowSeed is unchanged.
174
266
  // ---------------------------------------------------------------------------
175
267
 
176
268
  /**
177
269
  * Verify a registry JSON body string.
270
+ *
178
271
  * @param {string} body
179
272
  * @param {object} [opts]
273
+ * @param {string} [opts.metaKeyPem] PEM-encoded Ed25519 SPKI key to verify against.
274
+ * Defaults to IJFW_REGISTRY_META_KEY_PEM. Sentinel `<embedded>` also resolves
275
+ * to the embedded key.
180
276
  * @param {boolean} [opts.allowSeed] Accept unsigned (null-signature) registries (bootstrap mode)
181
277
  * @returns {{ valid: boolean, registry: object|null, reason: string, warnings?: string[] }}
182
278
  */
183
279
  export function verifyRegistry(body, opts = {}) {
280
+ // Resolve the meta-key (back-compat with v1.4.1 callers that omitted opts).
281
+ const rawMetaKey = opts.metaKeyPem;
282
+ const metaKeyPem =
283
+ rawMetaKey === undefined ||
284
+ rawMetaKey === null ||
285
+ rawMetaKey === '' ||
286
+ rawMetaKey === META_KEY_SENTINEL
287
+ ? IJFW_REGISTRY_META_KEY_PEM
288
+ : rawMetaKey;
289
+
184
290
  let parsed;
185
291
  try {
186
292
  parsed = JSON.parse(body);
@@ -207,8 +313,6 @@ export function verifyRegistry(body, opts = {}) {
207
313
  }
208
314
 
209
315
  // Signature verification — null signature is only accepted in seed/bootstrap mode.
210
- // In production (default), a null signature is rejected to prevent MITM serving
211
- // an unsigned registry that bypasses all publisher trust decisions.
212
316
  if (parsed.signature === null) {
213
317
  const allowSeed = opts.allowSeed === true || process.env.IJFW_ALLOW_SEED_REGISTRY === '1';
214
318
  if (!allowSeed) {
@@ -228,7 +332,7 @@ export function verifyRegistry(body, opts = {}) {
228
332
 
229
333
  let metaKey;
230
334
  try {
231
- metaKey = createPublicKey(IJFW_REGISTRY_META_KEY_PEM);
335
+ metaKey = createPublicKey(metaKeyPem);
232
336
  } catch (err) {
233
337
  return { valid: false, registry: null, reason: `meta-key parse failed: ${err.message}` };
234
338
  }
@@ -257,13 +361,303 @@ export function verifyRegistry(body, opts = {}) {
257
361
  }
258
362
 
259
363
  // ---------------------------------------------------------------------------
260
- // Cache helpers
364
+ // loadRegistrySources — read ~/.ijfw/registries.json or fall back to single-source.
261
365
  // ---------------------------------------------------------------------------
262
366
 
367
+ function defaultPublicSource() {
368
+ return {
369
+ name: 'public',
370
+ url: DEFAULT_REGISTRY_URL,
371
+ meta_key_pem: IJFW_REGISTRY_META_KEY_PEM,
372
+ priority: 0,
373
+ publisher_ttl_ms: PUBLISHER_TTL_MS,
374
+ revocation_ttl_ms: REVOCATION_TTL_MS,
375
+ };
376
+ }
377
+
263
378
  /**
264
- * Read the cached registry from disk.
265
- * @returns {Promise<{registry: object|null, cachedAt: number|null, stale: boolean}>}
379
+ * Read and validate ~/.ijfw/registries.json. Returns the priority-ordered list
380
+ * of resolved sources.
381
+ *
382
+ * If the file is missing, return the legacy single-source default. If the file
383
+ * is present but malformed, throw RegistrySourcesError (SEC-M-01 — fatal).
384
+ *
385
+ * @returns {Promise<Array<{name:string,url:string,meta_key_pem:string,priority:number,publisher_ttl_ms:number,revocation_ttl_ms:number}>>}
266
386
  */
387
+ export async function loadRegistrySources() {
388
+ let raw;
389
+ try {
390
+ raw = await readFile(registriesConfigPath(), 'utf8');
391
+ } catch (err) {
392
+ if (err && err.code === 'ENOENT') {
393
+ // Back-compat: single public registry default.
394
+ return [defaultPublicSource()];
395
+ }
396
+ throw new RegistrySourcesError(
397
+ `cannot read ${registriesConfigPath()}: ${err.message}`,
398
+ { reason: 'unreadable' },
399
+ );
400
+ }
401
+
402
+ let parsed;
403
+ try {
404
+ parsed = JSON.parse(raw);
405
+ } catch (err) {
406
+ throw new RegistrySourcesError(
407
+ `registries.json JSON parse failed: ${err.message}`,
408
+ { reason: 'parse_error' },
409
+ );
410
+ }
411
+
412
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
413
+ throw new RegistrySourcesError(
414
+ 'registries.json root must be a JSON object',
415
+ { reason: 'schema_root' },
416
+ );
417
+ }
418
+
419
+ if (parsed.schema_version !== '1.0') {
420
+ throw new RegistrySourcesError(
421
+ `registries.json schema_version must equal "1.0", got ${JSON.stringify(parsed.schema_version)}`,
422
+ { reason: 'schema_version' },
423
+ );
424
+ }
425
+
426
+ if (!Array.isArray(parsed.registries) || parsed.registries.length === 0) {
427
+ throw new RegistrySourcesError(
428
+ 'registries.json: registries must be a non-empty array',
429
+ { reason: 'schema_registries' },
430
+ );
431
+ }
432
+
433
+ const seenNames = new Set();
434
+ const sources = [];
435
+
436
+ for (let i = 0; i < parsed.registries.length; i++) {
437
+ const entry = parsed.registries[i];
438
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
439
+ throw new RegistrySourcesError(
440
+ `registries[${i}]: must be an object`,
441
+ { reason: 'schema_entry' },
442
+ );
443
+ }
444
+ if (typeof entry.name !== 'string' || !SOURCE_NAME_PATTERN.test(entry.name)) {
445
+ throw new RegistrySourcesError(
446
+ `registries[${i}].name: must match /^[a-z0-9_-]+$/, got ${JSON.stringify(entry.name)}`,
447
+ { reason: 'schema_name' },
448
+ );
449
+ }
450
+ if (seenNames.has(entry.name)) {
451
+ throw new RegistrySourcesError(
452
+ `registries[${i}].name: duplicate source name ${JSON.stringify(entry.name)}`,
453
+ { reason: 'duplicate_name' },
454
+ );
455
+ }
456
+ seenNames.add(entry.name);
457
+ if (typeof entry.url !== 'string') {
458
+ throw new RegistrySourcesError(
459
+ `registries[${i}].url: must be a string`,
460
+ { reason: 'schema_url' },
461
+ );
462
+ }
463
+ let parsedUrl;
464
+ try {
465
+ parsedUrl = new URL(entry.url);
466
+ } catch {
467
+ throw new RegistrySourcesError(
468
+ `registries[${i}].url: invalid URL ${JSON.stringify(entry.url)}`,
469
+ { reason: 'schema_url' },
470
+ );
471
+ }
472
+ if (parsedUrl.protocol !== 'https:') {
473
+ throw new RegistrySourcesError(
474
+ `registries[${i}].url: must use HTTPS, got ${parsedUrl.protocol}`,
475
+ { reason: 'schema_url' },
476
+ );
477
+ }
478
+
479
+ // meta_key_pem resolution.
480
+ let metaKeyPem;
481
+ if (
482
+ entry.meta_key_pem === undefined ||
483
+ entry.meta_key_pem === null ||
484
+ entry.meta_key_pem === META_KEY_SENTINEL ||
485
+ entry.meta_key_pem === ''
486
+ ) {
487
+ metaKeyPem = IJFW_REGISTRY_META_KEY_PEM;
488
+ } else if (typeof entry.meta_key_pem !== 'string') {
489
+ throw new RegistrySourcesError(
490
+ `registries[${i}].meta_key_pem: must be a string PEM or the sentinel "<embedded>"`,
491
+ { reason: 'schema_meta_key' },
492
+ );
493
+ } else {
494
+ // Validate it parses.
495
+ try {
496
+ createPublicKey(entry.meta_key_pem);
497
+ } catch (err) {
498
+ throw new RegistrySourcesError(
499
+ `registries[${i}].meta_key_pem: PEM parse failed: ${err.message}`,
500
+ { reason: 'schema_meta_key' },
501
+ );
502
+ }
503
+ metaKeyPem = entry.meta_key_pem;
504
+ }
505
+
506
+ const priority =
507
+ typeof entry.priority === 'number' && Number.isFinite(entry.priority)
508
+ ? entry.priority
509
+ : i;
510
+
511
+ const publisher_ttl_ms =
512
+ typeof entry.publisher_ttl_ms === 'number' && entry.publisher_ttl_ms > 0
513
+ ? entry.publisher_ttl_ms
514
+ : PUBLISHER_TTL_MS;
515
+ const revocation_ttl_ms =
516
+ typeof entry.revocation_ttl_ms === 'number' && entry.revocation_ttl_ms > 0
517
+ ? entry.revocation_ttl_ms
518
+ : REVOCATION_TTL_MS;
519
+
520
+ sources.push({
521
+ name: entry.name,
522
+ url: entry.url,
523
+ meta_key_pem: metaKeyPem,
524
+ priority,
525
+ publisher_ttl_ms,
526
+ revocation_ttl_ms,
527
+ });
528
+ }
529
+
530
+ sources.sort((a, b) => a.priority - b.priority);
531
+ return sources;
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Per-source cache helpers (B14/B17). One file per source, atomic + locked.
536
+ // ---------------------------------------------------------------------------
537
+
538
+ const PER_SOURCE_CACHE_KEYS = [
539
+ 'publishers',
540
+ 'publishers_fetched_at',
541
+ 'revoked',
542
+ 'revocation_fetched_at',
543
+ 'source_name',
544
+ 'source_url',
545
+ ];
546
+
547
+ function emptySourceCache(source) {
548
+ return {
549
+ publishers: {},
550
+ publishers_fetched_at: null,
551
+ revoked: [],
552
+ revocation_fetched_at: null,
553
+ source_name: source.name,
554
+ source_url: source.url,
555
+ };
556
+ }
557
+
558
+ /**
559
+ * Read the per-source cache. On any parse / shape / source_name mismatch error,
560
+ * returns `{ cache: emptySourceCache(source), corrupt: true, reason }` so the
561
+ * caller can both surface the warning AND avoid silent fall-through (SEC-M-04).
562
+ *
563
+ * @returns {Promise<{ cache: object, corrupt: boolean, reason?: string }>}
564
+ */
565
+ export async function readSourceCache(source) {
566
+ const path = perSourceCachePath(source.name);
567
+ let raw;
568
+ try {
569
+ raw = await readFile(path, 'utf8');
570
+ } catch (err) {
571
+ if (err && err.code === 'ENOENT') {
572
+ return { cache: emptySourceCache(source), corrupt: false };
573
+ }
574
+ return {
575
+ cache: emptySourceCache(source),
576
+ corrupt: true,
577
+ reason: `read_failed:${err.code || err.message}`,
578
+ };
579
+ }
580
+ let parsed;
581
+ try {
582
+ parsed = JSON.parse(raw);
583
+ } catch (err) {
584
+ return {
585
+ cache: emptySourceCache(source),
586
+ corrupt: true,
587
+ reason: `parse_error:${err.message}`,
588
+ };
589
+ }
590
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
591
+ return { cache: emptySourceCache(source), corrupt: true, reason: 'shape_root' };
592
+ }
593
+ for (const k of PER_SOURCE_CACHE_KEYS) {
594
+ if (!(k in parsed)) {
595
+ return {
596
+ cache: emptySourceCache(source),
597
+ corrupt: true,
598
+ reason: `missing_field:${k}`,
599
+ };
600
+ }
601
+ }
602
+ if (parsed.source_name !== source.name) {
603
+ return {
604
+ cache: emptySourceCache(source),
605
+ corrupt: true,
606
+ reason: `source_name_mismatch:expected=${source.name},got=${parsed.source_name}`,
607
+ };
608
+ }
609
+ if (
610
+ parsed.publishers === null ||
611
+ typeof parsed.publishers !== 'object' ||
612
+ Array.isArray(parsed.publishers)
613
+ ) {
614
+ return { cache: emptySourceCache(source), corrupt: true, reason: 'publishers_shape' };
615
+ }
616
+ if (!Array.isArray(parsed.revoked)) {
617
+ return { cache: emptySourceCache(source), corrupt: true, reason: 'revoked_shape' };
618
+ }
619
+ return { cache: parsed, corrupt: false };
620
+ }
621
+
622
+ async function atomicWriteJson(filePath, payload) {
623
+ // R12-H-02: ensure the file's parent dir exists, not just ~/.ijfw/state —
624
+ // trusted-publishers.json lives at ~/.ijfw, not ~/.ijfw/state.
625
+ await mkdir(dirname(filePath), { recursive: true });
626
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
627
+ await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
628
+ await rename(tmp, filePath);
629
+ }
630
+
631
+ /**
632
+ * Mutate the per-source cache inside an exclusive fs-lock.
633
+ * @param {object} source — entry from loadRegistrySources()
634
+ * @param {(cache: object) => object|Promise<object>} mutator
635
+ */
636
+ export async function withSourceCache(source, mutator) {
637
+ return withFsLock(perSourceLockPath(source.name), async () => {
638
+ const { cache, corrupt, reason } = await readSourceCache(source);
639
+ if (corrupt) {
640
+ // The mutator still gets a fresh empty cache to write into. We surface
641
+ // `reason` via the mutator's return value when it wants to record it.
642
+ const next = await mutator({ ...cache, _corruptReason: reason });
643
+ if (next && typeof next === 'object') {
644
+ delete next._corruptReason;
645
+ await atomicWriteJson(perSourceCachePath(source.name), next);
646
+ }
647
+ return { corrupt: true, reason };
648
+ }
649
+ const next = await mutator(cache);
650
+ if (next && typeof next === 'object') {
651
+ await atomicWriteJson(perSourceCachePath(source.name), next);
652
+ }
653
+ return { corrupt: false };
654
+ });
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Legacy single-source cache helpers (kept for v1.4.1 back-compat tests).
659
+ // ---------------------------------------------------------------------------
660
+
267
661
  export async function readCachedRegistry() {
268
662
  let raw;
269
663
  try {
@@ -282,10 +676,6 @@ export async function readCachedRegistry() {
282
676
  return { registry: parsed.registry ?? null, cachedAt, stale };
283
677
  }
284
678
 
285
- /**
286
- * Write the registry to the local cache.
287
- * @param {object} registry
288
- */
289
679
  export async function writeCachedRegistry(registry) {
290
680
  await mkdir(ijfwStateDir(), { recursive: true });
291
681
  const payload = JSON.stringify({ cached_at: Date.now(), registry }, null, 2) + '\n';
@@ -294,6 +684,7 @@ export async function writeCachedRegistry(registry) {
294
684
 
295
685
  // ---------------------------------------------------------------------------
296
686
  // applyRegistry — merge publishers + process revocations
687
+ // (single-source path, retained for v1.4.1 callers + tests)
297
688
  // ---------------------------------------------------------------------------
298
689
 
299
690
  /**
@@ -303,116 +694,426 @@ export async function writeCachedRegistry(registry) {
303
694
  * @returns {Promise<{added: string[], removed: string[], unchanged: string[], rejected: string[]}>}
304
695
  */
305
696
  export async function applyRegistry(registry, _opts = {}) {
306
- const added = [];
307
- const removed = [];
308
- const unchanged = [];
309
- const rejected = [];
310
-
311
- // Read current trust store
312
- const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
313
- let store = { publishers: {} };
314
- try {
315
- const raw = await readFile(tpPath, 'utf8');
316
- const parsed = JSON.parse(raw);
317
- if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
318
- store = parsed;
697
+ // R12-H-02: serialise the entire read-merge-write window with the
698
+ // trust-store lock so two concurrent callers cannot drop each other's
699
+ // revocations. Atomic tmp+rename inside the lock for both files.
700
+ await mkdir(ijfwStateDir(), { recursive: true });
701
+ return await withFsLock(trustStoreLockPath(), async () => {
702
+ const added = [];
703
+ const removed = [];
704
+ const unchanged = [];
705
+ const rejected = [];
706
+
707
+ // Read current trust store
708
+ const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
709
+ let store = { publishers: {} };
710
+ try {
711
+ const raw = await readFile(tpPath, 'utf8');
712
+ const parsed = JSON.parse(raw);
713
+ if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
714
+ store = parsed;
715
+ }
716
+ } catch { /* absent or malformed → start fresh */ }
717
+
718
+ // Read / update revoked list
719
+ let revokedStore = { revoked: [] };
720
+ try {
721
+ const raw = await readFile(revokedPublishersPath(), 'utf8');
722
+ const parsed = JSON.parse(raw);
723
+ if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
724
+ } catch { /* absent → start fresh */ }
725
+
726
+ const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
727
+
728
+ // Process revocations first
729
+ for (const entry of (registry.revoked || [])) {
730
+ const { keyId } = entry;
731
+ if (!keyId) continue;
732
+ if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
733
+ delete store.publishers[keyId];
734
+ removed.push(keyId);
735
+ }
736
+ if (!revokedSet.has(keyId)) {
737
+ revokedSet.add(keyId);
738
+ revokedStore.revoked.push({
739
+ keyId,
740
+ revoked_at: entry.revoked_at || new Date().toISOString(),
741
+ reason: entry.reason || '',
742
+ superseded_by: entry.superseded_by || null,
743
+ });
744
+ }
319
745
  }
320
- } catch { /* absent or malformed → start fresh */ }
321
746
 
322
- // Read / update revoked list
323
- let revokedStore = { revoked: [] };
324
- try {
325
- const raw = await readFile(revokedPublishersPath(), 'utf8');
326
- const parsed = JSON.parse(raw);
327
- if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
328
- } catch { /* absent → start fresh */ }
329
-
330
- const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
331
-
332
- // Process revocations first
333
- for (const entry of (registry.revoked || [])) {
334
- const { keyId } = entry;
335
- if (!keyId) continue;
336
- if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
337
- delete store.publishers[keyId];
338
- removed.push(keyId);
747
+ // Merge publishers
748
+ for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
749
+ if (!entry || typeof entry.publicKey !== 'string') {
750
+ rejected.push(keyId);
751
+ continue;
752
+ }
753
+ if (revokedSet.has(keyId)) {
754
+ rejected.push(keyId);
755
+ continue;
756
+ }
757
+ try {
758
+ const key = createPublicKey(entry.publicKey);
759
+ const der = key.export({ type: 'spki', format: 'der' });
760
+ const fp = createHash('sha256').update(der).digest('hex');
761
+ if (fp !== keyId) {
762
+ rejected.push(keyId);
763
+ continue;
764
+ }
765
+ } catch {
766
+ rejected.push(keyId);
767
+ continue;
768
+ }
769
+
770
+ if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
771
+ unchanged.push(keyId);
772
+ } else {
773
+ store.publishers[keyId] = {
774
+ name: entry.name,
775
+ publicKey: entry.publicKey,
776
+ verified_at: entry.verified_at,
777
+ metadata: entry.metadata,
778
+ added_at: new Date().toISOString(),
779
+ };
780
+ added.push(keyId);
781
+ }
339
782
  }
340
- // Record in revoked-publishers.json if not already there
341
- if (!revokedSet.has(keyId)) {
342
- revokedSet.add(keyId);
343
- revokedStore.revoked.push({
344
- keyId,
345
- revoked_at: entry.revoked_at || new Date().toISOString(),
346
- reason: entry.reason || '',
347
- superseded_by: entry.superseded_by || null,
783
+
784
+ await mkdir(join(homedir(), '.ijfw'), { recursive: true });
785
+ await atomicWriteJson(tpPath, store);
786
+ await atomicWriteJson(revokedPublishersPath(), revokedStore);
787
+
788
+ return { added, removed, unchanged, rejected };
789
+ }, { staleMs: 30_000 });
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // applyMultiRegistry (B14) — multi-source merge with priority + global revoke.
794
+ // ---------------------------------------------------------------------------
795
+
796
+ /**
797
+ * Apply verified-per-source registries to the local trust store.
798
+ *
799
+ * @param {Array<{source:object, registry:object|null, rejected?:{reason:string,detail?:string}}>} appliedSources
800
+ * Pre-fetched, pre-verified registries paired with their source descriptor.
801
+ * `registry: null` indicates the source was skipped; `rejected` is non-null
802
+ * in that case.
803
+ * @returns {Promise<{sources: Array, global_revocations: Array, conflicts: Array}>}
804
+ */
805
+ export async function applyMultiRegistry(appliedSources) {
806
+ // R12-H-02: serialise the entire read-merge-write window with the
807
+ // trust-store lock. Without this two concurrent `trust-registry --emergency`
808
+ // calls interleave their read+write phases and the later writer drops the
809
+ // earlier writer's revocations. Atomic tmp+rename inside the lock for both
810
+ // trust-store files. Stale-recovery defaults are sufficient (30s).
811
+ await mkdir(ijfwStateDir(), { recursive: true });
812
+ return await withFsLock(trustStoreLockPath(), async () => {
813
+ const sources = [];
814
+ const conflicts = [];
815
+ const global_revocations = [];
816
+
817
+ // Read current local trust + revoked stores.
818
+ const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
819
+ let store = { publishers: {} };
820
+ try {
821
+ const raw = await readFile(tpPath, 'utf8');
822
+ const parsed = JSON.parse(raw);
823
+ if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
824
+ store = parsed;
825
+ }
826
+ } catch { /* absent */ }
827
+
828
+ let revokedStore = { revoked: [] };
829
+ try {
830
+ const raw = await readFile(revokedPublishersPath(), 'utf8');
831
+ const parsed = JSON.parse(raw);
832
+ if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
833
+ } catch { /* absent */ }
834
+
835
+ const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
836
+
837
+ // PASS 1: revocations from ALL sources are global (defense-in-depth).
838
+ for (const { source, registry, rejected } of appliedSources) {
839
+ if (!registry) continue;
840
+ for (const entry of registry.revoked || []) {
841
+ const { keyId } = entry;
842
+ if (!keyId) continue;
843
+ if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
844
+ delete store.publishers[keyId];
845
+ }
846
+ if (!revokedSet.has(keyId)) {
847
+ revokedSet.add(keyId);
848
+ const rev = {
849
+ keyId,
850
+ revoked_at: entry.revoked_at || new Date().toISOString(),
851
+ reason: entry.reason || '',
852
+ superseded_by: entry.superseded_by || null,
853
+ source: source.name,
854
+ };
855
+ revokedStore.revoked.push(rev);
856
+ global_revocations.push({ keyId, source: source.name });
857
+ }
858
+ }
859
+ // Stash the source preamble for the per-source report.
860
+ sources.push({
861
+ name: source.name,
862
+ url: source.url,
863
+ added: [],
864
+ removed: [],
865
+ unchanged: [],
866
+ rejected: rejected ? [{ name: source.name, reason: rejected.reason, detail: rejected.detail }] : [],
867
+ skipped: !registry,
348
868
  });
349
869
  }
350
- }
351
870
 
352
- // Merge publishers
353
- for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
354
- if (!entry || typeof entry.publicKey !== 'string') {
355
- rejected.push(keyId);
356
- continue;
871
+ // PASS 2: publishers in priority order (lowest .priority first).
872
+ const ordered = appliedSources
873
+ .filter((x) => x.registry)
874
+ .slice()
875
+ .sort((a, b) => a.source.priority - b.source.priority);
876
+ const claimedBy = new Map(); // keyId -> source name
877
+
878
+ for (const { source, registry } of ordered) {
879
+ const report = sources.find((s) => s.name === source.name);
880
+ for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
881
+ if (!entry || typeof entry.publicKey !== 'string') {
882
+ report.rejected.push({ keyId, reason: 'malformed' });
883
+ continue;
884
+ }
885
+ if (revokedSet.has(keyId)) {
886
+ report.rejected.push({ keyId, reason: 'revoked' });
887
+ continue;
888
+ }
889
+ try {
890
+ const key = createPublicKey(entry.publicKey);
891
+ const der = key.export({ type: 'spki', format: 'der' });
892
+ const fp = createHash('sha256').update(der).digest('hex');
893
+ if (fp !== keyId) {
894
+ report.rejected.push({ keyId, reason: 'fingerprint_mismatch' });
895
+ continue;
896
+ }
897
+ } catch {
898
+ report.rejected.push({ keyId, reason: 'pubkey_parse_failed' });
899
+ continue;
900
+ }
901
+
902
+ if (claimedBy.has(keyId)) {
903
+ const winner = claimedBy.get(keyId);
904
+ if (winner !== source.name) {
905
+ conflicts.push({ keyId, winner, also_in: source.name });
906
+ report.rejected.push({ keyId, reason: 'priority_conflict', winner });
907
+ }
908
+ continue;
909
+ }
910
+
911
+ claimedBy.set(keyId, source.name);
912
+ const existing = store.publishers[keyId];
913
+ if (existing && existing.publicKey === entry.publicKey) {
914
+ report.unchanged.push(keyId);
915
+ } else {
916
+ store.publishers[keyId] = {
917
+ name: entry.name,
918
+ publicKey: entry.publicKey,
919
+ verified_at: entry.verified_at,
920
+ metadata: entry.metadata,
921
+ source: source.name,
922
+ added_at: new Date().toISOString(),
923
+ };
924
+ if (existing) report.removed.push(keyId);
925
+ report.added.push(keyId);
926
+ }
927
+ }
357
928
  }
358
- // Don't re-add revoked publishers
359
- if (revokedSet.has(keyId)) {
360
- rejected.push(keyId);
361
- continue;
929
+
930
+ await atomicWriteJson(tpPath, store);
931
+ await atomicWriteJson(revokedPublishersPath(), revokedStore);
932
+
933
+ return { sources, global_revocations, conflicts };
934
+ }, { staleMs: 30_000 });
935
+ }
936
+
937
+ // ---------------------------------------------------------------------------
938
+ // refreshTrustFromAllRegistries (B14 + B17) — high-level federated entry.
939
+ //
940
+ // For each source:
941
+ // 1. Decide which parts are stale per split-TTL (publishers / revoked).
942
+ // 2. Fetch the stale part(s); fall back to cache on failure.
943
+ // 3. Verify against the source's meta key.
944
+ // 4. Update the per-source cache inside `withFsLock`.
945
+ // 5. Collect into appliedSources → applyMultiRegistry.
946
+ // ---------------------------------------------------------------------------
947
+
948
+ /**
949
+ * @param {object} [opts]
950
+ * @param {Function} [opts.fetchImpl] — injectable for tests; (url, source, part) -> {ok,body,error}
951
+ * @param {boolean} [opts.allowSeed]
952
+ * @param {boolean} [opts.emergency] — bypass all caches; force fetch.
953
+ * @param {boolean} [opts.refreshPublishers] — override TTL check, fetch publishers.
954
+ * @param {boolean} [opts.refreshRevocation] — override TTL check, fetch revocation.
955
+ * @param {Array} [opts.sources] — override loadRegistrySources() (tests).
956
+ * @returns {Promise<{ok:boolean, multi:object|null, warnings:string[], error:string|null}>}
957
+ */
958
+ export async function refreshTrustFromAllRegistries(opts = {}) {
959
+ const warnings = [];
960
+ let sources;
961
+ try {
962
+ sources = opts.sources || (await loadRegistrySources());
963
+ } catch (err) {
964
+ if (err instanceof RegistrySourcesError) {
965
+ return { ok: false, multi: null, warnings, error: `registries.json ${err.reason}: ${err.message}` };
362
966
  }
363
- // Verify fingerprint matches keyId
364
- try {
365
- const key = createPublicKey(entry.publicKey);
366
- const der = key.export({ type: 'spki', format: 'der' });
367
- const fp = createHash('sha256').update(der).digest('hex');
368
- if (fp !== keyId) {
369
- rejected.push(keyId);
370
- continue;
967
+ throw err;
968
+ }
969
+
970
+ const appliedSources = [];
971
+
972
+ for (const source of sources) {
973
+ const fetchImpl = typeof opts.fetchImpl === 'function'
974
+ ? (url, part) => opts.fetchImpl(url, source, part)
975
+ : null;
976
+
977
+ let cache;
978
+ let corruptReason = null;
979
+ const cacheRead = await readSourceCache(source);
980
+ cache = cacheRead.cache;
981
+ if (cacheRead.corrupt) {
982
+ corruptReason = cacheRead.reason;
983
+ const msg = `[ijfw] WARNING: cache for source '${source.name}' corrupt (${cacheRead.reason}) — ignored; falling back to network`;
984
+ process.stderr.write(msg + '\n');
985
+ warnings.push(msg);
986
+ }
987
+
988
+ const now = Date.now();
989
+ const pubAge = cache.publishers_fetched_at ? now - Date.parse(cache.publishers_fetched_at) : Infinity;
990
+ const revAge = cache.revocation_fetched_at ? now - Date.parse(cache.revocation_fetched_at) : Infinity;
991
+ const wantPublishers = opts.emergency || opts.refreshPublishers || corruptReason || pubAge > source.publisher_ttl_ms;
992
+ const wantRevocation = opts.emergency || opts.refreshRevocation || corruptReason || revAge > source.revocation_ttl_ms;
993
+
994
+ let mergedRegistry = null;
995
+ let fetchError = null;
996
+
997
+ // Always fetch the full registry when we want publishers (the response is
998
+ // the source of truth for both parts). When ONLY revocation is stale we
999
+ // still issue a fetch (server returns the same JSON; CDN can cache
1000
+ // separately if it cares about ?part=revoked).
1001
+ if (wantPublishers || wantRevocation) {
1002
+ const part = wantPublishers ? 'all' : 'revoked';
1003
+ const fetched = await fetchRegistry(source.url, { fetchImpl, part });
1004
+ if (!fetched.ok) {
1005
+ fetchError = fetched.error;
1006
+ } else {
1007
+ const verified = verifyRegistry(fetched.body, {
1008
+ metaKeyPem: source.meta_key_pem,
1009
+ allowSeed: opts.allowSeed,
1010
+ });
1011
+ if (!verified.valid) {
1012
+ fetchError = `verify failed: ${verified.reason}`;
1013
+ } else {
1014
+ if (verified.warnings) {
1015
+ for (const w of verified.warnings) {
1016
+ const wMsg = `[ijfw] WARNING (source=${source.name}): ${w}`;
1017
+ process.stderr.write(wMsg + '\n');
1018
+ warnings.push(wMsg);
1019
+ }
1020
+ }
1021
+ mergedRegistry = verified.registry;
1022
+ }
371
1023
  }
372
- } catch {
373
- rejected.push(keyId);
374
- continue;
375
1024
  }
376
1025
 
377
- if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
378
- unchanged.push(keyId);
379
- } else {
380
- store.publishers[keyId] = {
381
- name: entry.name,
382
- publicKey: entry.publicKey,
383
- verified_at: entry.verified_at,
384
- metadata: entry.metadata,
385
- added_at: new Date().toISOString(),
1026
+ // Decide what cache to write + which registry to apply.
1027
+ if (mergedRegistry) {
1028
+ // Update cache.
1029
+ const nowIso = new Date().toISOString();
1030
+ const updatedCache = {
1031
+ publishers: wantPublishers ? mergedRegistry.publishers : cache.publishers,
1032
+ publishers_fetched_at: wantPublishers ? nowIso : cache.publishers_fetched_at,
1033
+ revoked: mergedRegistry.revoked || cache.revoked,
1034
+ revocation_fetched_at: nowIso,
1035
+ source_name: source.name,
1036
+ source_url: source.url,
386
1037
  };
387
- added.push(keyId);
1038
+ try {
1039
+ await withSourceCache(source, () => updatedCache);
1040
+ } catch (err) {
1041
+ warnings.push(`[ijfw] WARNING: failed to write cache for source '${source.name}': ${err.message}`);
1042
+ }
1043
+ appliedSources.push({
1044
+ source,
1045
+ registry: {
1046
+ registry_version: '1.0',
1047
+ updated_at: mergedRegistry.updated_at,
1048
+ publishers: updatedCache.publishers,
1049
+ revoked: updatedCache.revoked,
1050
+ signature: mergedRegistry.signature,
1051
+ },
1052
+ });
1053
+ continue;
388
1054
  }
389
- }
390
1055
 
391
- // Persist
392
- await mkdir(join(homedir(), '.ijfw'), { recursive: true });
393
- await writeFile(tpPath, JSON.stringify(store, null, 2) + '\n', 'utf8');
1056
+ // Fetch failed or we didn't need to fetch. Try cache fallback.
1057
+ const cacheUsable =
1058
+ !corruptReason &&
1059
+ (cache.publishers_fetched_at !== null || cache.revocation_fetched_at !== null);
1060
+ if (cacheUsable) {
1061
+ appliedSources.push({
1062
+ source,
1063
+ registry: {
1064
+ registry_version: '1.0',
1065
+ updated_at: cache.publishers_fetched_at || cache.revocation_fetched_at,
1066
+ publishers: cache.publishers || {},
1067
+ revoked: cache.revoked || [],
1068
+ signature: 'cached',
1069
+ },
1070
+ });
1071
+ if (fetchError) {
1072
+ const msg = `[ijfw] WARNING: source '${source.name}' fetch failed (${fetchError}) — using cached entries`;
1073
+ process.stderr.write(msg + '\n');
1074
+ warnings.push(msg);
1075
+ }
1076
+ continue;
1077
+ }
394
1078
 
395
- await mkdir(ijfwStateDir(), { recursive: true });
396
- await writeFile(
397
- revokedPublishersPath(),
398
- JSON.stringify(revokedStore, null, 2) + '\n',
399
- 'utf8',
400
- );
1079
+ // No cache, no fresh fetch → skip with rejected marker (SEC-M-01 skip-with-warning).
1080
+ appliedSources.push({
1081
+ source,
1082
+ registry: null,
1083
+ rejected: {
1084
+ reason: corruptReason ? 'cache_corrupt' : 'fetch_failed',
1085
+ detail: fetchError || corruptReason || 'no cache available',
1086
+ },
1087
+ });
1088
+ const msg = corruptReason
1089
+ ? `[ijfw] WARNING: source '${source.name}' skipped (cache_corrupt: ${corruptReason})`
1090
+ : `[ijfw] WARNING: source '${source.name}' skipped (${fetchError || 'no cache'})`;
1091
+ process.stderr.write(msg + '\n');
1092
+ warnings.push(msg);
1093
+ }
401
1094
 
402
- return { added, removed, unchanged, rejected };
1095
+ const multi = await applyMultiRegistry(appliedSources);
1096
+ return { ok: true, multi, warnings, error: null };
403
1097
  }
404
1098
 
405
1099
  // ---------------------------------------------------------------------------
406
- // refreshTrustFromRegistry — high-level entry point
1100
+ // refreshTrustFromRegistry — v1.4.1 single-source entry point.
1101
+ //
1102
+ // v1.4.3 wires this through the new multi-source pipeline when no explicit
1103
+ // `url` (i.e. caller wants federated default) AND no `fetchImpl` injected for
1104
+ // the single-source legacy tests. The legacy URL-passing tests continue to use
1105
+ // the old single-source code path so they keep their explicit assertions.
407
1106
  // ---------------------------------------------------------------------------
408
1107
 
409
1108
  /**
410
1109
  * Fetch → verify → apply → cache. The main entry point for the CLI.
411
1110
  * Falls back to cache on offline; warns if stale.
412
1111
  *
1112
+ * v1.4.3 (B17): opts.partTtl now influences cache TTL when split fetching;
1113
+ * opts.emergency forces a no-cache refresh.
1114
+ *
413
1115
  * @param {string} [url]
414
1116
  * @param {object} [opts]
415
- * @param {Function} [opts.fetchImpl] - injectable for tests
416
1117
  * @returns {Promise<{ok: boolean, diff: object|null, fromCache: boolean, warnings: string[], error: string|null}>}
417
1118
  */
418
1119
  export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
@@ -420,14 +1121,12 @@ export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts
420
1121
 
421
1122
  const fetched = await fetchRegistry(url, opts);
422
1123
  if (!fetched.ok) {
423
- // Offline path — try cache
424
1124
  const cached = await readCachedRegistry();
425
1125
  if (cached.registry) {
426
1126
  if (cached.stale) warnings.push(`offline and cache is stale (age > ${CACHE_TTL_MS / 3600000}h) — trust store not updated`);
427
1127
  else warnings.push('offline — using cached registry');
428
1128
  return { ok: true, diff: null, fromCache: true, warnings, error: null };
429
1129
  }
430
- // No cache either — return existing trust store untouched
431
1130
  warnings.push(`offline and no cache available — trust store unchanged: ${fetched.error}`);
432
1131
  return { ok: true, diff: null, fromCache: false, warnings, error: null };
433
1132
  }
@@ -436,7 +1135,6 @@ export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts
436
1135
  if (!verified.valid) {
437
1136
  return { ok: false, diff: null, fromCache: false, warnings, error: `registry verify failed: ${verified.reason}` };
438
1137
  }
439
- // Seed-mode: surface warnings loudly on stderr so bootstrap operators notice.
440
1138
  if (verified.warnings && verified.warnings.length > 0) {
441
1139
  for (const w of verified.warnings) {
442
1140
  process.stderr.write(`[ijfw] WARNING: ${w}\n`);
@@ -460,30 +1158,16 @@ import { resolve as pathResolve, relative as pathRelative, isAbsolute as pathIsA
460
1158
  import { cwd } from 'node:process';
461
1159
 
462
1160
  /**
463
- * Cross-platform path-under-cwd check. Returns true when `targetPath` resolves
464
- * under the current working directory. Uses path.relative + path.isAbsolute so
465
- * the result is correct on Windows (\ separators, drive letters) and POSIX.
466
- *
467
- * @param {string} targetPath
468
- * @returns {boolean}
1161
+ * Cross-platform path-under-cwd check.
469
1162
  */
470
1163
  function isUnderCwd(targetPath) {
471
1164
  const abs = pathResolve(targetPath);
472
1165
  const cwdAbs = pathResolve(cwd());
473
1166
  if (abs === cwdAbs) return true;
474
1167
  const rel = pathRelative(cwdAbs, abs);
475
- // Outside cwd: relative path starts with '..' or is absolute (e.g. on
476
- // Windows when target is on a different drive than cwd).
477
1168
  return rel !== '' && !rel.startsWith('..') && !pathIsAbsolute(rel);
478
1169
  }
479
1170
 
480
- /**
481
- * Generate a registry meta-keypair and persist under ~/.ijfw/keys/<keyId>/.
482
- * Writes a `meta-role.txt` marker file to distinguish from publisher keys.
483
- *
484
- * @param {string} author
485
- * @returns {Promise<{keyId: string, publicKey: string, dir: string}>}
486
- */
487
1171
  export async function keygenMeta(author) {
488
1172
  const { publicKey, privateKey } = generateKeyPairSync('ed25519');
489
1173
  const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
@@ -500,7 +1184,6 @@ export async function keygenMeta(author) {
500
1184
  try { await chmod(join(dir, 'private.pem'), 0o600); } catch { /* best-effort */ }
501
1185
  try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
502
1186
 
503
- // Meta-role marker
504
1187
  await writeFile(
505
1188
  join(dir, 'meta-role.txt'),
506
1189
  `meta\n${author || 'unknown'}\n${new Date().toISOString()}\n`,
@@ -510,19 +1193,7 @@ export async function keygenMeta(author) {
510
1193
  return { keyId, publicKey: pubPem, dir };
511
1194
  }
512
1195
 
513
- /**
514
- * Sign a registry JSON file in place. Updates `signature` and `updated_at`.
515
- * Writes atomically.
516
- *
517
- * Path must resolve under cwd() (path traversal defence).
518
- *
519
- * @param {string} registryPath
520
- * @param {object} [opts]
521
- * @param {string} [opts.privateKeyPem] - if not provided, loads from ~/.ijfw/keys/<keyId>/private.pem
522
- * @returns {Promise<{ok: boolean, error?: string}>}
523
- */
524
1196
  export async function signRegistry(registryPath, opts = {}) {
525
- // Path security: must resolve under cwd (cross-platform).
526
1197
  const abs = pathResolve(registryPath);
527
1198
  if (!isUnderCwd(registryPath)) {
528
1199
  return { ok: false, error: `path traversal rejected: ${registryPath}` };
@@ -542,10 +1213,8 @@ export async function signRegistry(registryPath, opts = {}) {
542
1213
  return { ok: false, error: `JSON parse failed: ${err.message}` };
543
1214
  }
544
1215
 
545
- // Find the private key: prefer opts.privateKeyPem, else load from meta-keypair dir
546
1216
  let privPem = opts.privateKeyPem || null;
547
1217
  if (!privPem) {
548
- // Find meta-key in ~/.ijfw/keys/
549
1218
  const keysDir = join(homedir(), '.ijfw', 'keys');
550
1219
  let keyDirs = [];
551
1220
  try {
@@ -574,7 +1243,6 @@ export async function signRegistry(registryPath, opts = {}) {
574
1243
  return { ok: false, error: `private key parse failed: ${err.message}` };
575
1244
  }
576
1245
 
577
- // Update updated_at, clear old signature, compute canonical bytes, sign
578
1246
  registry.updated_at = new Date().toISOString();
579
1247
  delete registry.signature;
580
1248
  const bytes = registryCanonicalBytes(registry);
@@ -590,16 +1258,7 @@ export async function signRegistry(registryPath, opts = {}) {
590
1258
  return { ok: true };
591
1259
  }
592
1260
 
593
- /**
594
- * Verify a registry JSON file's signature against the compiled-in meta-key.
595
- *
596
- * Path must resolve under cwd().
597
- *
598
- * @param {string} registryPath
599
- * @returns {Promise<{ok: boolean, valid: boolean, reason: string}>}
600
- */
601
1261
  export async function verifyRegistryFile(registryPath) {
602
- // Path security (cross-platform).
603
1262
  const abs = pathResolve(registryPath);
604
1263
  if (!isUnderCwd(registryPath)) {
605
1264
  return { ok: false, valid: false, reason: `path traversal rejected: ${registryPath}` };
@@ -616,4 +1275,15 @@ export async function verifyRegistryFile(registryPath) {
616
1275
  return { ok: true, valid: result.valid, reason: result.reason };
617
1276
  }
618
1277
 
619
- export { DEFAULT_REGISTRY_URL, FALLBACK_REGISTRY_URL, CACHE_TTL_MS, IJFW_REGISTRY_META_KEY_PEM };
1278
+ export {
1279
+ DEFAULT_REGISTRY_URL,
1280
+ FALLBACK_REGISTRY_URL,
1281
+ CACHE_TTL_MS,
1282
+ PUBLISHER_TTL_MS,
1283
+ REVOCATION_TTL_MS,
1284
+ IJFW_REGISTRY_META_KEY_PEM,
1285
+ META_KEY_SENTINEL,
1286
+ SOURCE_NAME_PATTERN,
1287
+ perSourceCachePath,
1288
+ perSourceLockPath,
1289
+ };