// 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')
}
})