// rotation-guard — can we still CHANGE each hosted DID? (hyper-social #47)
//
// The failure this guards is invisible by construction: wj62 kept serving perfectly for six weeks with
// its rotation authority already gone, because SERVING needs only the signing key. So the property under
// test is not "does it compute a boolean" but "does it distinguish the three answers that matter":
//
// true we hold a rotation key — the DID is still fixable
// false we hold none — frozen FOREVER, and no later action undoes it
// null we could not tell (no network, encrypted vault)
//
// The null case is the one worth a test of its own. Collapsing "could not check" into "false" is exactly
// the bug the 5A gate shipped (a user told their device was revoked when the check simply could not run),
// and collapsing it the other way hides a dead identity behind a green tick.
//
// fetchImpl is injected, so this is offline and deterministic — it never touches plc.directory.
// Run: bare test/rotation-guard.test.js
import '../lib/bare-globals.js'
import test from 'brittle'
import { deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
import { collectRotationHeld, localRotationCandidates } from '../lib/rotation-guard.js'
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DID = 'did:plc:rotationguardtest00000'
const signingKey = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH).didKey
const recoveryKey = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH).didKey
const cfg = { dids: [{ did: DID, deviceMnemonic: MNEMONIC }] }
const plc = (rotationKeys) => async () => ({ ok: true, json: async () => ({ rotationKeys }) })
test('a rotation key we can derive reports held:true and names its source', async (t) => {
const r = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey]) })).get(DID)
t.is(r.held, true, 'held')
t.ok(r.heldBy && r.heldBy.includes('deviceMnemonic'), 'names WHERE the key came from — an operator must be able to find it')
t.is(r.rotationKeyCount, 1)
})
test('it matches on EITHER derivation path, because genesis used the signing path', async (t) => {
// buildAttestedIdentity called resolveKey(rootMnemonic) with no path argument, so the root fell through
// to deriveAtprotoKey's default — ATPROTO_SIGNING_PATH. A check that only tried the recovery path would
// have reported wj62's own root as absent.
const r = (await collectRotationHeld({ cfg, fetchImpl: plc([signingKey]) })).get(DID)
t.is(r.held, true, 'signing-path root is found too')
})
test('a DID whose rotation key we do NOT hold reports held:false — the alarm', async (t) => {
// Derived from a DIFFERENT BIP39 test vector, so it cannot collide with anything on the machine.
// This previously hard-coded wj62's real rotation key as the "foreign" example — which passed only
// while that key was missing from this box, and started failing (correctly!) the moment the vault
// was restored. A test whose verdict depends on the operator's live secrets is not a test.
const foreign = deriveAtprotoKey('legal winner thank year wave sausage worth useful legal winner thank yellow', ATPROTO_SIGNING_PATH).didKey
const r = (await collectRotationHeld({ cfg, fetchImpl: plc([foreign]) })).get(DID)
t.is(r.held, false, 'false, not null — this is a determined verdict')
t.ok(r.reason && r.reason.includes('no local secret'), 'and says why')
})
test('an unresolvable DID reports null, NEVER false', async (t) => {
const dead = async () => { throw new Error('network is down') }
const r = (await collectRotationHeld({ cfg, fetchImpl: dead })).get(DID)
t.is(r.held, null, 'a check that could not run is not a NO')
t.ok(r.reason && r.reason.includes('plc.directory'), 'and reports the cause rather than a bare null')
t.is(r.rotationKeyCount, null, 'and claims no count it did not observe')
})
test('holding the ONLY rotation key is still flagged as a single point of failure', async (t) => {
const one = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey]) })).get(DID)
t.is(one.held, true)
t.is(one.singleKeyRisk, true, 'held AND fragile are different facts, reported separately')
const two = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey, signingKey]) })).get(DID)
t.is(two.singleKeyRisk, false, 'a second key clears the risk')
t.is(two.rotationKeyCount, 2)
})
test('candidate collection never throws on unusable material', async (t) => {
const junk = { dids: [{ did: DID, deviceMnemonic: 'not a valid mnemonic at all', signingKey: 'zzzz' }] }
const { owned } = localRotationCandidates(junk)
t.ok(owned instanceof Map, 'returns a map rather than throwing — one bad entry must not blind the whole check')
})