// Attaching an identity to hosting, and detaching it again — the holder's half of the invite ceremony.
//
// ── WHY THIS IS A SEPARATE ACT FROM CREATING AN IDENTITY ─────────────────────────────────────────
// buildGenesisOp makes handle and pdsUrl optional, so an identity can be minted P2P-only: it names
// nobody's infrastructure and needs nobody's permission to exist (hyper-social #54 §8). What is scarce
// is not identity, it is somebody's servers. So being fronted by a membrane (pds.7rnx.net) or a
// fediverse bridge (ap.7rnx.net) is gated by an invite, and accepting one is THIS: a later PLC update
// adding a domain handle and a service endpoint.
//
// The ordering is forced rather than chosen. An update op must chain onto a genesis already in the log
// (checkUpdateOp wants prev == the head CID), so genesis is submitted at creation and the invite extends
// it. That is what keeps creation genuinely free and joining genuinely reversible.
//
// ── ⛔ THE OPERATOR CANNOT DO THIS FOR YOU ───────────────────────────────────────────────────────
// A PLC update must be signed by a CURRENT rotation key, and createSwarmIdentity puts exactly one there:
// the root recovery key, which never leaves its owner (device-node.js: a device receives the signing key
// and NEVER the root, "so it can't derive the recovery key or self-recover"). An operator can grant
// infrastructure and can add a DID to the bridge's fronted set, but cannot alter someone's identity
// document. The ceremony is two-sided by construction, not by policy — which is the property that makes
// "our infrastructure" a service rather than custody.
//
// ── SHAPE, FOR THE UI THAT WILL DRIVE IT ─────────────────────────────────────────────────────────
// These are OFFLINE and PURE: they take the current head op and return a signed op. Nothing here
// resolves, submits, or talks to a network — PlcDirectory.submitUpdate does that, and it is deliberately
// somebody else's call, because it is the irreversible half.
//
// So an atproto-bridge web UI splits cleanly: the UI issues the invite and knows the handle + endpoint;
// the HOLDER (a phone unlocking its vault, or an operator with the mnemonic) calls these and submits.
// Neither side can complete the ceremony alone.
//
// A PLC op REPLACES the whole document state — it is not a patch — so every field not being changed must
// be carried forward explicitly. Dropping one silently removes a key or a service.
import { buildUpdateOp, signOp, keyFromPrivate } from './atproto-identity.js'
const PDS_SERVICE = 'atproto_pds'
const PDS_TYPE = 'AtprotoPersonalDataServer'
const aka = (handle) => 'at://' + String(handle).replace(/^at:\/\//, '')
/**
* Refuse locally what the directory would refuse remotely.
*
* Signing with a key that is not a current rotation key produces an op the directory rejects, and
* finding that out by submitting is a wasted round trip against a permanent public log — and a
* confusing one, because the rejection names a CID, not the mistake. The likely wrong key is the
* #atproto signing key, which a device legitimately holds and which has zero PLC authority.
*/
function assertRotationSigner (prevSignedOp, signerPrivateKey) {
const rot = (prevSignedOp && prevSignedOp.rotationKeys) || []
let didKey = null
try { didKey = keyFromPrivate(signerPrivateKey).didKey } catch { didKey = null }
if (!didKey || !rot.includes(didKey)) {
throw new Error(
'hosting: signer is not a current rotation key of this identity — a PLC update must be signed by ' +
'the recovery key, not the #atproto signing key'
)
}
}
/** Sign an unsigned op, after checking the signer may. */
function finish (prevSignedOp, unsigned, signerPrivateKey) {
assertRotationSigner(prevSignedOp, signerPrivateKey)
return { ...unsigned, sig: signOp(unsigned, signerPrivateKey) }
}
/**
* Accept hosting: claim `handle` and name `pdsUrl` as this identity's PDS.
*
* Also the MOVE operation — pointing at a different host is one op, not a detach plus an attach, because
* two ops would leave the identity briefly homeless in a log that never forgets.
*
* @param {object} opts
* @param {object} opts.prevSignedOp the current head op (genesis, or the last update)
* @param {string} opts.handle e.g. 'flaky.7rnx.net'
* @param {string} opts.pdsUrl e.g. 'https://pds.7rnx.net'
* @param {Uint8Array} opts.signerPrivateKey a CURRENT rotation key — the root recovery key
* @returns {object} a signed plc_operation, ready for PlcDirectory.submitUpdate
*/
export function attachHosting ({ prevSignedOp, handle, pdsUrl, signerPrivateKey } = {}) {
if (!prevSignedOp) throw new Error('attachHosting: prevSignedOp required')
if (!handle) throw new Error('attachHosting: handle required')
if (!pdsUrl) throw new Error('attachHosting: pdsUrl required')
const prevServices = prevSignedOp.services || {}
const prevAka = prevSignedOp.alsoKnownAs || []
const wantAka = [aka(handle)]
// ⛔ REFUSE A NO-OP. Every accepted op is a permanent, public append to a global log. Re-running an
// invite that already succeeded must not add a second identical entry to someone's history — and a
// web UI WILL re-run it, because a user who does not see a change presses the button again.
const already = prevServices[PDS_SERVICE] &&
prevServices[PDS_SERVICE].endpoint === pdsUrl &&
prevAka.length === 1 && prevAka[0] === wantAka[0]
if (already) {
throw new Error('attachHosting: this identity is already hosted at ' + pdsUrl + ' as ' + handle + ' — nothing to append')
}
// The handle REPLACES rather than accumulates: atproto treats the first alias as the identity's handle,
// and an identity that silently kept every host it ever tried would advertise handles that no longer
// resolve. Other services are carried forward — a community DID's feed generator and labeler are not
// this operation's business.
const unsigned = buildUpdateOp({
prevSignedOp,
rotationDidKeys: prevSignedOp.rotationKeys,
verificationMethods: prevSignedOp.verificationMethods,
alsoKnownAs: wantAka,
services: { ...prevServices, [PDS_SERVICE]: { type: PDS_TYPE, endpoint: pdsUrl } }
})
return finish(prevSignedOp, unsigned, signerPrivateKey)
}
/**
* Leave hosting: release the handle and stop naming a PDS, returning the identity to P2P-only.
*
* This is what makes accepting an invite reversible, and it is why the invite gates INFRASTRUCTURE
* rather than existence: leaving costs the identity nothing it was born with. The DID, its keys and its
* repo are untouched — only the document's claims about where to reach it change.
*
* ⚠ Reversible is not retroactive. Content already replicated to a membrane or a relay has been read,
* and detaching does not unsay it. This changes where the network is TOLD to look, nothing more.
*/
export function detachHosting ({ prevSignedOp, signerPrivateKey } = {}) {
if (!prevSignedOp) throw new Error('detachHosting: prevSignedOp required')
const services = { ...(prevSignedOp.services || {}) }
const hadPds = !!services[PDS_SERVICE]
delete services[PDS_SERVICE]
const hadAka = (prevSignedOp.alsoKnownAs || []).length > 0
if (!hadPds && !hadAka) {
throw new Error('detachHosting: this identity is already P2P-only — nothing to append')
}
const unsigned = buildUpdateOp({
prevSignedOp,
rotationDidKeys: prevSignedOp.rotationKeys,
verificationMethods: prevSignedOp.verificationMethods,
alsoKnownAs: [],
services
})
return finish(prevSignedOp, unsigned, signerPrivateKey)
}