| … | … | @@ -64,47 +64,52 @@ function b (hex) { |
| 64 | 64 | return out |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | | // The DID's CURRENT rotationKeys. Offline first — a diagnostic that needs the network to answer is |
| 68 | | // one more thing that can hang — then plc.directory under a HARD deadline. |
| 67 | // The DID's CURRENT rotationKeys, from plc.directory under a HARD deadline, with the persisted |
| 68 | // genesis as an offline fallback. |
| 69 | 69 | // |
| 70 | 70 | // The deadline uses AbortController, NOT a `timeout` option: bare-fetch destructures only |
| 71 | 71 | // { body, method, headers, signal, agent } and silently DROPS `timeout`, so a timeout option here |
| 72 | 72 | // would be a phantom deadline that reads as handled in review and bounds nothing. |
| 73 | 73 | export async function rotationKeysFor (did, { storage = null, timeoutMs = 5000, plcUrl = 'https://plc.directory', fetchImpl = null } = {}) { |
| 74 | | const local = [ |
| 74 | // LIVE FIRST. This used to prefer the persisted genesis so the check worked at boot with no network — |
| 75 | // but a genesis op is a snapshot of the DID at MINT time, and rotationKeys are exactly the field that |
| 76 | // changes afterwards. So after a successful `rotation add` the check kept reporting the original single |
| 77 | // key and told the operator their DID was still one secret from frozen. A diagnostic that contradicts |
| 78 | // the live document right after you fix something is worse than no diagnostic (hyper-social #47). |
| 79 | // |
| 80 | // The cached genesis remains the OFFLINE fallback, but it is labelled so a stale answer can never be |
| 81 | // mistaken for an authoritative one. |
| 82 | let f = fetchImpl |
| 83 | if (!f && typeof globalThis.fetch !== 'function') { |
| 84 | try { await import('bare-fetch/global') } catch { /* fall through to the local fallback */ } |
| 85 | } |
| 86 | if (!f) f = typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null |
| 87 | |
| 88 | if (f) { |
| 89 | const ac = new AbortController() |
| 90 | const timer = setTimeout(() => ac.abort(), timeoutMs) |
| 91 | try { |
| 92 | const res = await f(`${plcUrl}/${did}/data`, { signal: ac.signal }) |
| 93 | if (res.ok) { |
| 94 | const doc = await res.json() |
| 95 | if (Array.isArray(doc.rotationKeys)) return { keys: doc.rotationKeys, source: 'plc.directory (live)' } |
| 96 | } |
| 97 | } catch { /* offline — fall through */ } finally { clearTimeout(timer) } |
| 98 | } |
| 99 | |
| 100 | for (const p of [ |
| 75 | 101 | path.join(HOME, '.config', 'atproto-pds', 'pending-genesis.json'), |
| 76 | 102 | storage ? path.join(storage, 'peer-identity.json') : null |
| 77 | | ].filter(Boolean) |
| 78 | | for (const p of local) { |
| 103 | ].filter(Boolean)) { |
| 79 | 104 | const j = readJson(p) |
| 80 | 105 | if (j && j.did === did) { |
| 81 | 106 | const rk = (j.signedOp && j.signedOp.rotationKeys) || j.rotationKeys |
| 82 | | if (Array.isArray(rk) && rk.length) return { keys: rk, source: p.replace(HOME, '~') } |
| 107 | if (Array.isArray(rk) && rk.length) { |
| 108 | return { keys: rk, source: p.replace(HOME, '~') + ' ⚠ GENESIS SNAPSHOT — may be stale, plc.directory was unreachable', stale: true } |
| 109 | } |
| 83 | 110 | } |
| 84 | 111 | } |
| 85 | | |
| 86 | | // Bare has no global fetch. hpp already depends on bare-fetch for exactly this (lib/notifications.js, |
| 87 | | // bin/hpp-onboard.js), and it is installed LAZILY so the offline path above never pays for it — and so |
| 88 | | // a node with no network still answers from local genesis instead of failing to load a module. |
| 89 | | let f = fetchImpl |
| 90 | | if (!f && typeof globalThis.fetch !== 'function') { |
| 91 | | try { await import('bare-fetch/global') } catch { /* fall through to the honest "no fetch" below */ } |
| 92 | | } |
| 93 | | if (!f) f = typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null |
| 94 | | if (!f) return { keys: null, source: 'no fetch available' } |
| 95 | | |
| 96 | | const ac = new AbortController() |
| 97 | | const timer = setTimeout(() => ac.abort(), timeoutMs) |
| 98 | | try { |
| 99 | | const res = await f(`${plcUrl}/${did}/data`, { signal: ac.signal }) |
| 100 | | if (!res.ok) return { keys: null, source: `plc.directory HTTP ${res.status}` } |
| 101 | | const doc = await res.json() |
| 102 | | return { keys: Array.isArray(doc.rotationKeys) ? doc.rotationKeys : null, source: 'plc.directory' } |
| 103 | | } catch (e) { |
| 104 | | return { keys: null, source: 'plc.directory unreachable: ' + ((e && e.message) || 'aborted') } |
| 105 | | } finally { |
| 106 | | clearTimeout(timer) |
| 107 | | } |
| 112 | return { keys: null, source: 'plc.directory unreachable and no local genesis found' } |
| 108 | 113 | } |
| 109 | 114 | |
| 110 | 115 | // Per-DID verdict. `held` is deliberately TRI-STATE: |
| … | … | @@ -117,7 +122,7 @@ export async function collectRotationHeld ({ cfg = {}, dids = null, storage = nu |
| 117 | 122 | const list = dids || (cfg.dids || []).map((d) => d.did) |
| 118 | 123 | const out = new Map() |
| 119 | 124 | for (const did of list) { |
| 120 | | const { keys, source } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl }) |
| 125 | const { keys, source, stale } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl }) |
| 121 | 126 | if (!keys) { |
| 122 | 127 | out.set(did, { held: null, reason: source, rotationKeyCount: null, singleKeyRisk: null, heldBy: null }) |
| 123 | 128 | continue |
| … | … | @@ -130,6 +135,7 @@ export async function collectRotationHeld ({ cfg = {}, dids = null, storage = nu |
| 130 | 135 | // Holding the ONLY rotation key is still a single point of failure. A DID can be `held: true` |
| 131 | 136 | // and one disk failure from wj62's outcome, so the two facts are reported separately. |
| 132 | 137 | singleKeyRisk: keys.length === 1, |
| 138 | stale: !!stale, |
| 133 | 139 | heldBy: hit ? hit.by : null |
| 134 | 140 | }) |
| 135 | 141 | } |