// adopt — admit ANOTHER DEVICE into an existing DID over a blind-pairing tunnel instead of a file.
//
// Lives in hpp (not in a client) because ANY node holding the DID must be able to mint an invite for the
// next device — desktop or phone, symmetrically. That is keet's model: `addDevice`
// (contract/members/index.js) is available to any active member and the adder gains nothing by
// performing it. While this lived in the phone's tree, only the phone could mint.
//
// ⛔ THIS ADDS A TRANSPORT, NOT A CEREMONY. hyper-proto-peer's `coowner-invite` / `coowner-join` already
// do the whole cryptographic job, and do it correctly:
// - chained attestation root -> existing -> joiner, with NO ROOT MNEMONIC anywhere (a node deliberately
// never stores one; only the root's PUBLIC key, which every node already carries)
// - the inviter pins `forceCreator` on itself, so growing the roster cannot silently demote it
// - the joiner is written with `joiner: true` and WITHOUT `indexer` — a plain writer
// Those two lines are the loser-club fork and the N=2 quorum trap, already solved. Re-implementing any of
// it here would be writing a second, worse copy. This module only replaces the hand-off.
//
// WHAT WAS ACTUALLY MISSING. The existing ceremony moves the bundle as a FILE between two machines:
// joiner$ hyper-proto-peer coowner-join <did> -> prints its device PUBLIC key
// existing$ hyper-proto-peer coowner-invite <did> --device <hex> -> writes a bundle file
// joiner$ hyper-proto-peer coowner-join <did> --bundle <file>
// That is fine between two servers you have shells on. It is useless for a phone: there is no shell, no
// scp, and the bundle CARRIES THE SHARED SIGNING KEY — so "move this secret by hand" is the worst
// possible instruction to give a user.
//
// The blind-pairing tunnel is strictly better than the file it replaces: one-time, owner-minted,
// end-to-end encrypted over the DHT, and the secret never touches a filesystem either side.
//
// ⚠ `coownerInvite` MUTATES THE LOCAL CONFIG (adds the member, pins forceCreator) as well as returning
// the bundle. So it must be called only AFTER the human approves — never speculatively on connection.
import { createPairInvite, joinPairInvite } from 'atproto-room/lib/pairing.js'
import { coownerInvite, coownerJoin } from './coowner.js'
// ── EXISTING DEVICE (desktop, or any already-attested device — NOT necessarily the root) ─────────────
//
// approve({ devicePublicKey, did }) -> boolean. Called when a phone reaches the tunnel and BEFORE any
// config is touched. Return false and the pairing is refused with the config untouched.
//
// Returns { inviteHex, waitForPeer, close } — same shape as createPairInvite, so a CLI or a UI can show
// the invite as text or a QR and then await the result.
export async function mintAdoptInvite ({ did, approve, configFile = null, swarm = null } = {}) {
if (!did) throw new Error('mintAdoptInvite requires { did }')
if (typeof approve !== 'function') throw new Error('mintAdoptInvite requires approve({ devicePublicKey, did }) -> boolean')
let result = null
const source = await createPairInvite({
swarm,
// The candidate's hello carries its device PUBLIC key. Same field the browser flow uses for a
// sub-key — the tunnel does not care what the bytes mean, only this handler does.
onSubkey: async (devicePublicKey) => {
const ok = await approve({ devicePublicKey, did })
if (!ok) {
// Refusing must leave NOTHING behind: no roster entry, no forceCreator, no backup file.
// Returning an error object rather than throwing keeps the tunnel's own teardown clean.
return { error: 'refused by the device owner' }
}
// Only now does anything mutate. coownerInvite adds the member, pins our creator role, writes the
// config (leaving a .bak), and hands back the bundle.
const r = await coownerInvite({ did, devicePublicKey, configFile })
result = { members: r.members, backup: r.backup, devicePublicKey }
return r.bundle
}
})
return {
inviteHex: source.inviteHex,
async waitForPeer () {
await source.waitForPeer()
return result // null iff refused
},
close: () => source.close()
}
}
// ── PHONE ────────────────────────────────────────────────────────────────────────────────────────────
//
// Two calls, because the device key must exist before there is anything to attest:
// 1. coownerJoin({ did }) — mints a device mnemonic if absent, returns its PUBLIC key.
// The mnemonic is written to config and never leaves the phone.
// 2. joinPairInvite(...) — sends that public key up the tunnel, gets the bundle back.
// 3. coownerJoin({ did, bundle }) — writes the entry, sets joiner:true, leaves indexer unset.
//
// Step 3 is what makes the phone a NODE rather than a client: the bundle carries the shared #atproto
// signing key, so from here the phone materialises and signs its own commits.
// `did` is OPTIONAL and normally omitted — the invite alone is the whole handoff. The device mints its
// key knowing nothing (a keypair is derived from a mnemonic, not from an identity), sends the public
// half up the tunnel, and learns the DID from the bundle that comes back — which coownerJoin then
// cross-checks. Same shape as favela-web's browser join (bin/favela-web.js:962). Pass `did` only to
// ASSERT which identity you expect; a mismatch then throws instead of joining the wrong one.
export async function joinAdoptInvite ({ did = null, inviteHex, configFile = null, swarm = null, onStep = () => {} } = {}) {
if (!inviteHex) throw new Error('joinAdoptInvite requires { inviteHex }')
const pre = await coownerJoin({ did, configFile })
onStep('device key ready', { devicePublicKey: pre.devicePublicKey })
const bundle = await joinPairInvite(inviteHex, { subKey: pre.devicePublicKey, swarm })
if (!bundle || bundle.error) throw new Error('adopt refused: ' + ((bundle && bundle.error) || 'no bundle'))
onStep('bundle received', { did: bundle.did })
const joined = await coownerJoin({ did: did || bundle.did, bundle, configFile })
onStep('joined', { members: joined.entry.members })
// The caller restarts the node; boot picks the entry up. Deliberately not restarting here — a pairing
// function that also manages process lifecycle is two things.
return { devicePublicKey: pre.devicePublicKey, entry: joined.entry, backup: joined.backup }
}