// adopt — the blind-pairing transport for hpp's coowner ceremony.
//
// What is under test is NOT the cryptography. hpp's coowner-invite/coowner-join already own that and have
// their own proofs (including test/coowner-join-role.test.js for the fork). What this file has to show is
// that swapping the FILE hand-off for a TUNNEL changes nothing about the outcome:
//
// - the phone ends up with the identical entry it would have got from a bundle file
// - `joiner: true` survives the transport (the loser-club fork — trap 1)
// - `indexer` is still unset (the N=2 quorum trap — trap 2)
// - the inviter still pins `forceCreator` on itself
// - a REFUSED pairing leaves BOTH configs untouched — no roster entry, no forceCreator, no key sent
//
// The last one is the one worth writing a test for: `coownerInvite` mutates local config as a side
// effect, so "approve" must gate the mutation and not merely the reply.
//
// Run: node test/adopt.test.mjs
// Bare, not node: coowner.js imports bare-fs, so the whole ceremony only runs under the Bare runtime.
import fsm from 'bare-fs'
import os from 'bare-os'
import pathm from 'bare-path'
const { mkdtempSync, rmSync, writeFileSync, readFileSync } = fsm
const tmpdir = () => os.tmpdir()
const { join } = pathm
import createTestnet from '@hyperswarm/testnet'
import Hyperswarm from 'hyperswarm'
import IdentityKey from 'keet-identity-key'
import { deviceKeetKeyPair } from 'atproto-identity/lib/device-chain.js'
import b4a from 'b4a'
import { mintAdoptInvite, joinAdoptInvite } from '../lib/adopt.js'
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
const hex = (u) => b4a.toString(b4a.from(u), 'hex')
const DID = 'did:plc:adopttest'
const dir = mkdtempSync(join(tmpdir(), 'adopt-'))
const existingCfg = join(dir, 'existing.json')
const phoneCfg = join(dir, 'phone.json')
// ── an EXISTING device: a complete dids entry, exactly as a provisioned node has ──
// Bootstrap a real keet identity so the attestation chain is genuine rather than stubbed — the whole
// point is that the tunnel carries a real proof.
const rootMnemonic = IdentityKey.generateMnemonic()
const deviceMnemonic = IdentityKey.generateMnemonic()
const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
const idk = await IdentityKey.from({ mnemonic: rootMnemonic })
// ⚠ bootstrap is ASYNC (static async bootstrap; the instance method returns its promise). Without the
// await, deviceProof became an empty string and coownerInvite correctly refused to vouch — the guard
// caught a malformed fixture, which is the guard working.
const deviceProof = await idk.bootstrap(deviceKp.publicKey)
writeFileSync(existingCfg, JSON.stringify({
storage: dir,
dids: [{
did: DID,
handle: 'adopt.test',
deviceMnemonic,
deviceProof: hex(deviceProof),
signingKey: hex(new Uint8Array(32).fill(9)), // the shared #atproto key — a secret
rootIdentityKey: hex(idk.identityKeyPair.publicKey)
}]
}, null, 2))
writeFileSync(phoneCfg, JSON.stringify({ storage: dir, dids: [] }, null, 2))
const testnet = await createTestnet(3)
const mk = () => new Hyperswarm({ bootstrap: testnet.bootstrap })
console.log('\n# a REFUSED pairing must leave both sides untouched')
{
const before = readFileSync(existingCfg, 'utf8')
const sw1 = mk(); const sw2 = mk()
const src = await mintAdoptInvite({ did: DID, configFile: existingCfg, swarm: sw1, approve: () => false })
let threw = null
const waiting = src.waitForPeer().catch(() => null)
try {
await joinAdoptInvite({ did: DID, inviteHex: src.inviteHex, configFile: phoneCfg, swarm: sw2 })
} catch (e) { threw = e }
await waiting
assert(threw !== null, 'the phone is told it was refused rather than hanging')
assert(/refused/i.test(threw.message), 'and the reason says so: ' + threw.message)
assert(readFileSync(existingCfg, 'utf8') === before, 'the INVITER config is byte-identical — no member, no forceCreator')
const phone = JSON.parse(readFileSync(phoneCfg, 'utf8'))
const pe = phone.dids.find((d) => d.did === DID)
assert(pe && !pe.signingKey, 'the phone has a device key but NO signing key — the secret never moved')
try { await src.close() } catch {}
await sw1.destroy(); await sw2.destroy()
}
console.log('\n# an APPROVED pairing produces the same entry the file hand-off would')
{
const sw1 = mk(); const sw2 = mk()
let approvedWith = null
const src = await mintAdoptInvite({
did: DID,
configFile: existingCfg,
swarm: sw1,
approve: ({ devicePublicKey }) => { approvedWith = devicePublicKey; return true }
})
const waiting = src.waitForPeer()
const joined = await joinAdoptInvite({ did: DID, inviteHex: src.inviteHex, configFile: phoneCfg, swarm: sw2 })
const inviterResult = await waiting
assert(approvedWith === joined.devicePublicKey, 'the key approved is the key attested (no substitution)')
const phone = JSON.parse(readFileSync(phoneCfg, 'utf8')).dids.find((d) => d.did === DID)
assert(!!phone.deviceProof, 'the phone received a device proof over the tunnel')
assert(!!phone.signingKey, 'and the shared signing key — this is what makes it a NODE, not a client')
assert(!!phone.rootIdentityKey, 'and the root identity PUBLIC key')
// The two traps, verified through the transport rather than assumed.
assert(phone.joiner === true, 'TRAP 1: joiner:true survived the tunnel (the loser-club fork)')
assert(phone.forceCreator === undefined, 'TRAP 1: the joiner is NOT pinned as creator')
assert(phone.indexer === undefined, 'TRAP 2: indexer is unset — a plain writer (N=2 quorum trap)')
const inviter = JSON.parse(readFileSync(existingCfg, 'utf8')).dids.find((d) => d.did === DID)
assert(inviter.forceCreator === true, 'TRAP 1: the INVITER pinned its own creator role')
assert(inviter.members.includes(joined.devicePublicKey), 'the phone is in the inviter roster')
assert(phone.members.includes(joined.devicePublicKey), 'and in its own')
assert(inviterResult && inviterResult.devicePublicKey === joined.devicePublicKey, 'the inviter reports what it admitted')
// No root mnemonic anywhere: the inviter signed with its DEVICE key.
const cfgText = readFileSync(existingCfg, 'utf8') + readFileSync(phoneCfg, 'utf8')
assert(!cfgText.includes(rootMnemonic), 'THE ROOT MNEMONIC APPEARS IN NEITHER CONFIG — chained attestation, as designed')
try { await src.close() } catch {}
await sw1.destroy(); await sw2.destroy()
}
console.log('\n# INVITE-ONLY: the joiner is never told which DID it is joining')
{
// The single-code handoff. The phone knows nothing but the invite: it mints its key from a mnemonic
// (which needs no identity), sends the public half up, and learns the DID from the bundle — which
// coownerJoin then cross-checks. Same shape as favela-web's browser login (bin/favela-web.js:962).
const phone2 = join(dir, 'phone2.json')
writeFileSync(phone2, JSON.stringify({ storage: dir, dids: [] }, null, 2))
const sw1 = mk(); const sw2 = mk()
const src = await mintAdoptInvite({ did: DID, approve: () => true, configFile: existingCfg, swarm: sw1 })
const [joined] = await Promise.all([
joinAdoptInvite({ inviteHex: src.inviteHex, configFile: phone2, swarm: sw2 }), // ← NO did
src.waitForPeer()
])
await src.close()
assert(joined.entry.did === DID, 'the joiner landed on the right DID, learned from the bundle')
const cfg2 = JSON.parse(readFileSync(phone2, 'utf8'))
const phone = cfg2.dids.find((d) => d.did === DID)
assert(!!phone.signingKey, 'and holds the shared signing key — a NODE, from one pasted string')
assert(phone.joiner === true, 'joiner posture intact')
assert(!!cfg2.deviceMnemonic, 'its device key is DEVICE-scoped — minted before any DID was known')
await sw1.destroy(); await sw2.destroy()
}
await testnet.destroy()
rmSync(dir, { recursive: true, force: true })
console.log('\nADOPT OK: the tunnel carries what the file did — proof, signing key, and both trap postures')