// The invite ceremony, driven from a terminal — and shaped so a bridge web UI can drive the same thing.
//
// atproto-identity/lib/hosting.js is the pure half: given the head op it returns a signed update that
// claims a handle and names a PDS. This adds the three things a real ceremony needs around it — find the
// current head, get the recovery key out of wherever it lives, and SHOW what is about to happen before
// anything permanent occurs.
//
// ⛔ THE SUBMIT IS THE IRREVERSIBLE HALF, SO IT IS OPT-IN. Every function here previews by default and
// only submits when explicitly told to. A PLC append cannot be undone, edited or hidden; a ceremony whose
// default action is "write to a global public log" is the wrong default for a person AND for a web UI,
// where the same button gets double-clicked. Preview → confirm is the shape both want.
//
// ⛔ THE MNEMONIC IS NEVER AN ARGUMENT. Anything in argv is in `ps` for every user on the box, and in
// shell history. It comes from a FILE, or from the sealed vault the phone already produces — which is
// the path that matters, because it is the one where the secret was never typed anywhere.
//
// WHAT A WEB UI WOULD REUSE: describeChange (the preview it renders), resolveSigner (the unlock step),
// and hosting.js's attach/detach. What it would NOT reuse is the operator half — adding the DID to the
// bridge's frontedDids — because that is the OTHER side of the ceremony and the whole point is that
// neither side can complete it alone.
import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
import { openVault } from 'atproto-identity/lib/vault.js'
import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'
/**
* A human-readable account of what an op changes about a DID document.
*
* Pure, so the preview a person approves is computed the same way whatever renders it — a terminal now,
* a bridge web UI later. Reporting only what CHANGES is deliberate: a full document dump buries the one
* line that matters, and this is the moment someone decides whether to append to a permanent log.
*/
export function describeChange (prevSignedOp, nextOp) {
const before = prevSignedOp || {}
const akaOf = (o) => ((o && o.alsoKnownAs) || []).join(', ') || '(none)'
const pdsOf = (o) => (o && o.services && o.services.atproto_pds && o.services.atproto_pds.endpoint) || '(none)'
const changes = []
if (akaOf(before) !== akaOf(nextOp)) changes.push({ field: 'handle', from: akaOf(before), to: akaOf(nextOp) })
if (pdsOf(before) !== pdsOf(nextOp)) changes.push({ field: 'pds', from: pdsOf(before), to: pdsOf(nextOp) })
// Carried-forward fields are worth ASSERTING in the preview rather than assuming. A PLC op replaces the
// whole document, so "unchanged" is a property of this particular op, not a law — and silently dropping
// a rotation key is the one mistake nobody recovers from.
const kept = {
rotationKeys: JSON.stringify(before.rotationKeys) === JSON.stringify(nextOp.rotationKeys),
verificationMethods: JSON.stringify(before.verificationMethods) === JSON.stringify(nextOp.verificationMethods)
}
return { changes, kept, prev: nextOp.prev }
}
/**
* Get the recovery private key from wherever the holder keeps it.
*
* Two sources, and the vault is the one that matters: it is what `mint` seals on the phone, so the
* secret can travel to another device without ever being typed. The mnemonic file exists because paper
* is the other real backup and someone recovering from it has nothing else.
*
* ⛔ NOT FROM argv. See the header — argv is world-readable in ps.
*/
export function resolveSigner ({ mnemonic = null, vault = null, passphrase = null, did = null } = {}) {
let phrase = mnemonic
if (!phrase && vault) {
if (!passphrase) throw new Error('hosting: a sealed vault needs its passphrase')
if (!did) throw new Error('hosting: opening a vault needs the DID it was sealed for')
// openVault fails identically for a wrong passphrase, a tampered blob and the wrong identity — by
// design, so a failure does not tell an attacker which half they got right.
phrase = openVault({ envelope: vault, passphrase, did })
}
if (!phrase) throw new Error('hosting: no recovery mnemonic — pass --mnemonic-file or --vault')
const key = deriveAtprotoKey(String(phrase).trim(), ATPROTO_RECOVERY_PATH)
return { privateKey: key.privateKey, didKey: key.didKey }
}
function directory (plcUrl) {
return new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {})
}
/** What the directory currently says about this identity. */
export async function hostingShow ({ did, plcUrl = null } = {}) {
if (!did) throw new Error('hostingShow: did required')
const head = await directory(plcUrl).getHead(did)
return {
did,
handle: ((head && head.alsoKnownAs) || [])[0] || null,
pds: (head && head.services && head.services.atproto_pds && head.services.atproto_pds.endpoint) || null,
rotationKeys: (head && head.rotationKeys) || [],
head
}
}
/**
* Accept an invite: claim `handle`, name `pdsUrl`.
*
* Previews unless `submit` is true. Returns the signed op either way, so a caller that wants to show it,
* store it, or hand it to a person for a second look can — the op is only meaningful once appended, and
* holding it costs nothing.
*/
export async function hostingAttach ({ did, handle, pdsUrl, signer, plcUrl = null, submit = false } = {}) {
if (!did) throw new Error('hostingAttach: did required')
const plc = directory(plcUrl)
const prevSignedOp = await plc.getHead(did)
const op = attachHosting({ prevSignedOp, handle, pdsUrl, signerPrivateKey: signer.privateKey })
const preview = describeChange(prevSignedOp, op)
if (!submit) return { submitted: false, op, preview }
const data = await plc.submitUpdate({ did, signedOp: op })
return { submitted: true, op, preview, data }
}
/** Leave hosting, returning the identity to P2P-only. Same preview-by-default rule. */
export async function hostingDetach ({ did, signer, plcUrl = null, submit = false } = {}) {
if (!did) throw new Error('hostingDetach: did required')
const plc = directory(plcUrl)
const prevSignedOp = await plc.getHead(did)
const op = detachHosting({ prevSignedOp, signerPrivateKey: signer.privateKey })
const preview = describeChange(prevSignedOp, op)
if (!submit) return { submitted: false, op, preview }
const data = await plc.submitUpdate({ did, signedOp: op })
return { submitted: true, op, preview, data }
}
/** Render a preview the way the CLI prints it. Exported so the shape is testable. */
export function formatPreview ({ did, preview, submitted }) {
const lines = []
lines.push((submitted ? 'APPENDED to the PLC log for ' : 'WOULD append to the PLC log for ') + did)
for (const c of preview.changes) lines.push(' ' + c.field + ': ' + c.from + ' -> ' + c.to)
if (!preview.changes.length) lines.push(' (no changes)')
lines.push(' rotation keys: ' + (preview.kept.rotationKeys ? 'unchanged' : '⛔ CHANGED'))
lines.push(' signing key: ' + (preview.kept.verificationMethods ? 'unchanged' : '⛔ CHANGED'))
if (!submitted) lines.push('\nThis is PERMANENT and PUBLIC once submitted. Re-run with --yes to append it.')
return lines.join('\n')
}
/**
* The `hosting-*` subcommands.
*
* ⛔ PREVIEW IS THE DEFAULT AND --yes IS THE ONLY WAY PAST IT. A PLC append is permanent, public and
* unerasable, so the ceremony shows the exact change and stops. That is also the shape a web UI wants:
* render the preview, then a confirm button posts the same call with submit:true.
*/
export async function hostingCommand (argv) {
const sub = argv[0]
const flag = (n) => { const i = argv.indexOf('--' + n); return i < 0 ? null : argv[i + 1] }
const has = (n) => argv.includes('--' + n)
const did = argv.find((a) => a.startsWith('did:'))
const plcUrl = flag('plc')
if (!did) {
console.error('usage:\n' +
' hyper-proto-peer hosting-show <did> [--plc <url>]\n' +
' hyper-proto-peer hosting-attach <did> --handle <h> --pds <url> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n' +
' hyper-proto-peer hosting-detach <did> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n\n' +
'The secret is read from a FILE, never from the command line — argv is visible in ps.')
return 1
}
if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
const fs = await import('bare-fs')
if (sub === 'hosting-show') {
const s = await hostingShow({ did, plcUrl })
console.log(JSON.stringify({ did: s.did, handle: s.handle, pds: s.pds, rotationKeys: s.rotationKeys }, null, 2))
return 0
}
const read = (p) => fs.default.readFileSync(p, 'utf8').trim()
let signer
try {
signer = resolveSigner({
mnemonic: flag('mnemonic-file') ? read(flag('mnemonic-file')) : null,
vault: flag('vault') ? JSON.parse(read(flag('vault'))) : null,
passphrase: flag('passphrase-file') ? read(flag('passphrase-file')) : null,
did
})
} catch (e) { console.error(e.message); return 1 }
const submit = has('yes')
const r = sub === 'hosting-detach'
? await hostingDetach({ did, signer, plcUrl, submit })
: await hostingAttach({ did, handle: flag('handle'), pdsUrl: flag('pds'), signer, plcUrl, submit })
console.log(formatPreview({ did, preview: r.preview, submitted: r.submitted }))
if (r.submitted) {
console.log('\nNext, on the machine running the bridge: add this DID to `frontedDids` in its config.')
console.log('That half is the OPERATOR\'s and cannot be done from here — which is the point.')
}
return 0
}