OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / test / plc-directory.test.js

171 lines · javascript ref:
// PLC submit ceremony — OFFLINE-first. A mock in-memory PLC directory (injected as fetchImpl)
// exercises the full fetch-head → §6 local-validate → POST → confirm path with zero network.
// Nothing here touches the live plc.directory. (did-plc skill §5/§6.)

import test from 'brittle'
import {
  PlcDirectory, verifyOpSig, verifyOpSignedByAny, checkGenesisOp, checkUpdateOp
} from '../lib/plc-directory.js'
import { selfCustodyIdentity, deriveAtprotoKey, rotateVerificationMethod } from '../lib/atproto-identity.js'
import { opCid, didForCreateOp } from '../lib/did-plc.js'

const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }

function mkRes (status, body) {
  return {
    ok: status >= 200 && status < 400,
    status,
    statusText: 'status ' + status,
    async json () { if (typeof body === 'string') throw new Error('not json'); return body },
    async text () { return typeof body === 'string' ? body : JSON.stringify(body) }
  }
}

// A recording in-memory directory: appends any POSTed op, serves head/data from the log.
function recordingDirectory () {
  const logs = new Map() // did -> [op]
  const posts = []       // [{ did, op }]
  const fetchImpl = async (url, opts = {}) => {
    const u = new URL(url)
    const segs = u.pathname.split('/').filter(Boolean)
    const did = decodeURIComponent(segs[0] || '')
    const rest = segs.slice(1).join('/')
    if ((opts.method || 'GET') === 'POST') {
      const op = JSON.parse(opts.body)
      posts.push({ did, op })
      const arr = logs.get(did) || []; arr.push(op); logs.set(did, arr)
      return mkRes(200, 'OK')
    }
    const arr = logs.get(did)
    if (rest === 'log/last') { if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' }); return mkRes(200, arr[arr.length - 1]) }
    if (rest === 'data') {
      if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' })
      const op = arr[arr.length - 1]
      return mkRes(200, { did, verificationMethods: op.verificationMethods, rotationKeys: op.rotationKeys, alsoKnownAs: op.alsoKnownAs, services: op.services })
    }
    if (rest === '') { if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' }); return mkRes(200, { id: did }) }
    return mkRes(404, { error: 'NotFound' })
  }
  return { fetchImpl, logs, posts }
}

test('verifyOpSig / verifyOpSignedByAny: curve-aware, key-bound', (t) => {
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  const root = deriveAtprotoKey(ROOT)
  const device = deriveAtprotoKey(DEVICE)
  t.ok(verifyOpSig(id.genesisOp, root.didKey), 'genesis verifies vs its root did:key')
  t.absent(verifyOpSig(id.genesisOp, device.didKey), 'genesis does NOT verify vs the device key')
  t.ok(verifyOpSignedByAny(id.rotationOp, id.genesisOp.rotationKeys), 'rotation verifies vs a head rotationKey')
  t.absent(verifyOpSignedByAny(id.rotationOp, [device.didKey]), 'rotation not signed by the device key')
})

test('checkGenesisOp mirrors §6.2', (t) => {
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  t.ok(checkGenesisOp(id.genesisOp, id.did).ok, 'valid genesis passes')
  t.absent(checkGenesisOp(id.genesisOp, 'did:plc:wrongwrongwrongwrong0000').ok, 'DID mismatch fails')
  t.absent(checkGenesisOp({ ...id.genesisOp, prev: 'x' }, id.did).ok, 'non-null prev fails')
  t.absent(checkGenesisOp({ ...id.genesisOp, sig: id.rotationOp.sig }, id.did).ok, 'swapped/bad sig fails')
})

test('checkUpdateOp mirrors §6.3-4', (t) => {
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  t.ok(checkUpdateOp(id.rotationOp, id.genesisOp).ok, 'valid rotation chained on genesis passes')
  t.absent(checkUpdateOp({ ...id.rotationOp, prev: 'bafybogus' }, id.genesisOp).ok, 'wrong prev fails')
  // a rotation whose sig is by the DEVICE key (not a head rotationKey) must fail
  const device = deriveAtprotoKey(DEVICE)
  const badSigner = rotateVerificationMethod({ prevSignedOp: id.genesisOp, newAtprotoDidKey: device.didKey, signerPrivateKey: device.privateKey })
  t.absent(checkUpdateOp(badSigner, id.genesisOp).ok, 'sig not by a head rotationKey fails')
})

test('client: getHead/getData/submitRaw over injected fetch', async (t) => {
  const dir = recordingDirectory()
  const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  await plc.submitRaw(id.did, id.genesisOp)
  t.is(dir.posts.length, 1, 'POST recorded')
  t.alike(dir.posts[0].op, id.genesisOp, 'body is the unwrapped signed op')
  const head = await plc.getHead(id.did)
  t.is(opCid(head), opCid(id.genesisOp), 'getHead returns the head op (CID matches)')
  const data = await plc.getData(id.did)
  t.is(data.verificationMethods.atproto, id.genesisOp.verificationMethods.atproto, 'getData reflects genesis vm')
})

test('getHead normalizes wrapped/audit-extra responses (stable opCid)', async (t) => {
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  // a directory that returns the op with audit extras (cid/createdAt/nullified/did)
  const fetchImpl = async () => mkRes(200, { ...id.genesisOp, did: id.did, cid: 'bafyxxx', createdAt: '2026-07-04T00:00:00Z', nullified: false })
  const plc = new PlcDirectory({ fetchImpl })
  const head = await plc.getHead(id.did)
  t.is(opCid(head), opCid(id.genesisOp), 'audit extras stripped → CID matches the bare op')
})

test('happy path: submit genesis then rotation, confirm on-chain state', async (t) => {
  const dir = recordingDirectory()
  const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
  const root = deriveAtprotoKey(ROOT)
  const device = deriveAtprotoKey(DEVICE)
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: device.privateKey })

  const afterGenesis = await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
  t.is(afterGenesis.verificationMethods.atproto, root.didKey, 'after genesis, vm = root key')

  const afterRotate = await plc.submitUpdate({ did: id.did, signedOp: id.rotationOp })
  t.is(afterRotate.verificationMethods.atproto, device.didKey, 'after rotation, vm = device key (DID unchanged)')

  t.is(dir.posts.length, 2, 'exactly two ops appended')
  t.is(dir.posts[1].op.prev, opCid(dir.posts[0].op), 'rotation chains onto genesis (prev)')
  t.is(didForCreateOp(dir.posts[0].op), id.did, 'genesis still hashes to the DID')
})

test('ceremony builds the rotation itself: rotateVerificationMethodOnChain', async (t) => {
  const dir = recordingDirectory()
  const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
  const root = deriveAtprotoKey(ROOT)
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })

  const newDevice = deriveAtprotoKey(DEVICE2)
  const data = await plc.rotateVerificationMethodOnChain({ did: id.did, newAtprotoDidKey: newDevice.didKey, signerPrivateKey: root.privateKey })
  t.is(data.verificationMethods.atproto, newDevice.didKey, 'vm rotated to the NEW device key')
  t.is(dir.posts[dir.posts.length - 1].op.prev, opCid(id.genesisOp), 'built rotation chained onto the live head')
})

test('guard: submitUpdate refuses when prev != live head (no POST)', async (t) => {
  const dir = recordingDirectory()
  const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
  const postsBefore = dir.posts.length
  const stale = { ...id.rotationOp, prev: 'bafyreieqm  ' } // wrong prev
  await t.exception(plc.submitUpdate({ did: id.did, signedOp: stale }), /prev != live head/)
  t.is(dir.posts.length, postsBefore, 'no op was appended')
})

test('guard: invalid genesis rejected locally before any POST', async (t) => {
  const dir = recordingDirectory()
  const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  // wrong DID for the op → checkGenesisOp fails → must throw before POST
  await t.exception(plc.submitGenesis({ did: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', signedOp: id.genesisOp }), /invalid genesis/)
  t.is(dir.posts.length, 0, 'nothing submitted')
})

test('HTTP error surfaces with status + message', async (t) => {
  const fetchImpl = async (url, opts = {}) => {
    if ((opts.method || 'GET') === 'POST') return mkRes(400, { error: 'ImproperOperation', message: 'bad op' })
    return mkRes(404, { error: 'NotFound' })
  }
  const plc = new PlcDirectory({ fetchImpl })
  const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
  try {
    await plc.submitRaw(id.did, id.genesisOp)
    t.fail('should have thrown')
  } catch (e) {
    t.is(e.status, 400, 'error carries HTTP status')
    t.ok(/bad op/.test(e.message), 'error carries the directory message')
  }
})