OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / c7bb0cc

hosting: accept an invite, and leave again — the holder's half of the ceremony
Genesis is P2P-only, so an identity exists without anyone's permission. What is scarce is somebody's servers, so being fronted by pds.7rnx.net (or ap.7rnx.net, separately — bsky-visible and fediverse-visible are different consents) is gated by an invite, and accepting one is this: a later PLC update adding a domain handle and a service endpoint (hyper-social #54 §8). ⛔ THE OPERATOR CANNOT DO IT 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. So an operator can grant infrastructure and can front a DID, and cannot alter someone's identity document. The ceremony is two-sided by construction rather than by policy, which is what makes hosting a service instead of custody. Built as a primitive, not a script, because an atproto-bridge web UI should drive it: the UI issues the invite and knows the handle + endpoint; the HOLDER signs. These functions are offline and pure — they take the head op and return a signed op. Submitting stays PlcDirectory.submitUpdate's job, because that is the irreversible half and it should be somebody's explicit call. Detaching exists for the same reason attaching is gated: joining must be reversible, and leaving costs the identity nothing it was born with — same DID, same keys, same repo. Documented honestly as NOT retroactive: content already replicated has been read. Two refusals, both because a PLC append is permanent and public: a no-op attach (a web UI WILL re-run an invite when the user sees no change), and a signer that is not a current rotation key — the likely mistake being the #atproto signing key, which a device legitimately holds and which has zero PLC authority. Failing locally beats learning it from a rejection that names a CID rather than the mistake. A PLC op replaces the whole document state, so everything else is carried forward explicitly: rotation keys, verification methods, and other services — a community DID's feed generator and labeler are not this operation's business, in either direction. 7 tests, 20 assertions. Full suite 68/68, 232/232. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c7bb0cc92c5b9c16094d453c2bfcf5c4199f4655
by plankatron <fleeky@gmail.com> · 2026-08-26T01:04:34.000Z

Changed files (2) +243 -0

lib/hosting.js +142 -0
……@@ -0,0 +1,142 @@
1// Attaching an identity to hosting, and detaching it again — the holder's half of the invite ceremony.
2//
3// ── WHY THIS IS A SEPARATE ACT FROM CREATING AN IDENTITY ─────────────────────────────────────────
4// buildGenesisOp makes handle and pdsUrl optional, so an identity can be minted P2P-only: it names
5// nobody's infrastructure and needs nobody's permission to exist (hyper-social #54 §8). What is scarce
6// is not identity, it is somebody's servers. So being fronted by a membrane (pds.7rnx.net) or a
7// fediverse bridge (ap.7rnx.net) is gated by an invite, and accepting one is THIS: a later PLC update
8// adding a domain handle and a service endpoint.
9//
10// The ordering is forced rather than chosen. An update op must chain onto a genesis already in the log
11// (checkUpdateOp wants prev == the head CID), so genesis is submitted at creation and the invite extends
12// it. That is what keeps creation genuinely free and joining genuinely reversible.
13//
14// ── ⛔ THE OPERATOR CANNOT DO THIS FOR YOU ───────────────────────────────────────────────────────
15// A PLC update must be signed by a CURRENT rotation key, and createSwarmIdentity puts exactly one there:
16// the root recovery key, which never leaves its owner (device-node.js: a device receives the signing key
17// and NEVER the root, "so it can't derive the recovery key or self-recover"). An operator can grant
18// infrastructure and can add a DID to the bridge's fronted set, but cannot alter someone's identity
19// document. The ceremony is two-sided by construction, not by policy — which is the property that makes
20// "our infrastructure" a service rather than custody.
21//
22// ── SHAPE, FOR THE UI THAT WILL DRIVE IT ─────────────────────────────────────────────────────────
23// These are OFFLINE and PURE: they take the current head op and return a signed op. Nothing here
24// resolves, submits, or talks to a network — PlcDirectory.submitUpdate does that, and it is deliberately
25// somebody else's call, because it is the irreversible half.
26//
27// So an atproto-bridge web UI splits cleanly: the UI issues the invite and knows the handle + endpoint;
28// the HOLDER (a phone unlocking its vault, or an operator with the mnemonic) calls these and submits.
29// Neither side can complete the ceremony alone.
30//
31// A PLC op REPLACES the whole document state — it is not a patch — so every field not being changed must
32// be carried forward explicitly. Dropping one silently removes a key or a service.
33
34import { buildUpdateOp, signOp, keyFromPrivate } from './atproto-identity.js'
35
36const PDS_SERVICE = 'atproto_pds'
37const PDS_TYPE = 'AtprotoPersonalDataServer'
38
39const aka = (handle) => 'at://' + String(handle).replace(/^at:\/\//, '')
40
41/**
42 * Refuse locally what the directory would refuse remotely.
43 *
44 * Signing with a key that is not a current rotation key produces an op the directory rejects, and
45 * finding that out by submitting is a wasted round trip against a permanent public log — and a
46 * confusing one, because the rejection names a CID, not the mistake. The likely wrong key is the
47 * #atproto signing key, which a device legitimately holds and which has zero PLC authority.
48 */
49function assertRotationSigner (prevSignedOp, signerPrivateKey) {
50 const rot = (prevSignedOp && prevSignedOp.rotationKeys) || []
51 let didKey = null
52 try { didKey = keyFromPrivate(signerPrivateKey).didKey } catch { didKey = null }
53 if (!didKey || !rot.includes(didKey)) {
54 throw new Error(
55 'hosting: signer is not a current rotation key of this identity — a PLC update must be signed by ' +
56 'the recovery key, not the #atproto signing key'
57 )
58 }
59}
60
61/** Sign an unsigned op, after checking the signer may. */
62function finish (prevSignedOp, unsigned, signerPrivateKey) {
63 assertRotationSigner(prevSignedOp, signerPrivateKey)
64 return { ...unsigned, sig: signOp(unsigned, signerPrivateKey) }
65}
66
67/**
68 * Accept hosting: claim `handle` and name `pdsUrl` as this identity's PDS.
69 *
70 * Also the MOVE operation — pointing at a different host is one op, not a detach plus an attach, because
71 * two ops would leave the identity briefly homeless in a log that never forgets.
72 *
73 * @param {object} opts
74 * @param {object} opts.prevSignedOp the current head op (genesis, or the last update)
75 * @param {string} opts.handle e.g. 'flaky.7rnx.net'
76 * @param {string} opts.pdsUrl e.g. 'https://pds.7rnx.net'
77 * @param {Uint8Array} opts.signerPrivateKey a CURRENT rotation key — the root recovery key
78 * @returns {object} a signed plc_operation, ready for PlcDirectory.submitUpdate
79 */
80export function attachHosting ({ prevSignedOp, handle, pdsUrl, signerPrivateKey } = {}) {
81 if (!prevSignedOp) throw new Error('attachHosting: prevSignedOp required')
82 if (!handle) throw new Error('attachHosting: handle required')
83 if (!pdsUrl) throw new Error('attachHosting: pdsUrl required')
84
85 const prevServices = prevSignedOp.services || {}
86 const prevAka = prevSignedOp.alsoKnownAs || []
87 const wantAka = [aka(handle)]
88
89 // ⛔ REFUSE A NO-OP. Every accepted op is a permanent, public append to a global log. Re-running an
90 // invite that already succeeded must not add a second identical entry to someone's history — and a
91 // web UI WILL re-run it, because a user who does not see a change presses the button again.
92 const already = prevServices[PDS_SERVICE] &&
93 prevServices[PDS_SERVICE].endpoint === pdsUrl &&
94 prevAka.length === 1 && prevAka[0] === wantAka[0]
95 if (already) {
96 throw new Error('attachHosting: this identity is already hosted at ' + pdsUrl + ' as ' + handle + ' — nothing to append')
97 }
98
99 // The handle REPLACES rather than accumulates: atproto treats the first alias as the identity's handle,
100 // and an identity that silently kept every host it ever tried would advertise handles that no longer
101 // resolve. Other services are carried forward — a community DID's feed generator and labeler are not
102 // this operation's business.
103 const unsigned = buildUpdateOp({
104 prevSignedOp,
105 rotationDidKeys: prevSignedOp.rotationKeys,
106 verificationMethods: prevSignedOp.verificationMethods,
107 alsoKnownAs: wantAka,
108 services: { ...prevServices, [PDS_SERVICE]: { type: PDS_TYPE, endpoint: pdsUrl } }
109 })
110 return finish(prevSignedOp, unsigned, signerPrivateKey)
111}
112
113/**
114 * Leave hosting: release the handle and stop naming a PDS, returning the identity to P2P-only.
115 *
116 * This is what makes accepting an invite reversible, and it is why the invite gates INFRASTRUCTURE
117 * rather than existence: leaving costs the identity nothing it was born with. The DID, its keys and its
118 * repo are untouched — only the document's claims about where to reach it change.
119 *
120 * ⚠ Reversible is not retroactive. Content already replicated to a membrane or a relay has been read,
121 * and detaching does not unsay it. This changes where the network is TOLD to look, nothing more.
122 */
123export function detachHosting ({ prevSignedOp, signerPrivateKey } = {}) {
124 if (!prevSignedOp) throw new Error('detachHosting: prevSignedOp required')
125
126 const services = { ...(prevSignedOp.services || {}) }
127 const hadPds = !!services[PDS_SERVICE]
128 delete services[PDS_SERVICE]
129 const hadAka = (prevSignedOp.alsoKnownAs || []).length > 0
130 if (!hadPds && !hadAka) {
131 throw new Error('detachHosting: this identity is already P2P-only — nothing to append')
132 }
133
134 const unsigned = buildUpdateOp({
135 prevSignedOp,
136 rotationDidKeys: prevSignedOp.rotationKeys,
137 verificationMethods: prevSignedOp.verificationMethods,
138 alsoKnownAs: [],
139 services
140 })
141 return finish(prevSignedOp, unsigned, signerPrivateKey)
142}
test/hosting-attach.test.js +101 -0
……@@ -0,0 +1,101 @@
1// Attaching an identity to hosting — and detaching it again.
2//
3// This is the second half of the split in hyper-social #54 §8. Genesis is P2P-only: an identity names
4// nobody's infrastructure and needs nobody's permission to exist. Accepting an invite from a membrane
5// (pds.7rnx.net) or a fediverse bridge (ap.7rnx.net) is a LATER PLC UPDATE that adds a domain handle and
6// a service endpoint. Creation stays free; joining stays a separate, deliberate, REVERSIBLE act.
7//
8// ⛔ THE BRIDGE CANNOT DO THIS FOR YOU, AND THAT IS THE POINT. A PLC update must be signed by a CURRENT
9// rotation key (checkUpdateOp verifies against the head op's rotationKeys), and createSwarmIdentity puts
10// exactly one there: the root recovery key, which never leaves its owner. So an operator can grant
11// infrastructure and can front a DID, but cannot alter someone's identity document. The ceremony is
12// two-sided by construction, not by policy.
13//
14// Built as a primitive rather than a script because an atproto-bridge web UI should be able to drive it:
15// the UI issues the invite and knows the handle + endpoint, the HOLDER signs. These functions are the
16// holder's half, and they are offline — nothing here touches the network.
17//
18// Run: brittle-node test/hosting-attach.test.js
19import test from 'brittle'
20import { createSwarmIdentity, deriveAtprotoKey, ATPROTO_RECOVERY_PATH, ATPROTO_SIGNING_PATH } from '../lib/atproto-identity.js'
21import { checkUpdateOp } from '../lib/plc-directory.js'
22import { attachHosting, detachHosting } from '../lib/hosting.js'
23
24const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
25const HANDLE = 'flaky.7rnx.net'
26const PDS = 'https://pds.7rnx.net'
27
28const recovery = () => deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)
29const p2pOnly = () => createSwarmIdentity(MNEMONIC) // no handle, no pds — what mint produces
30
31test('attaching adds the handle and the PDS, and nothing else changes', (t) => {
32 const id = p2pOnly()
33 const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
34
35 t.alike(op.alsoKnownAs, ['at://' + HANDLE], 'the handle is claimed')
36 t.is(op.services.atproto_pds.endpoint, PDS, 'the PDS endpoint is named')
37 t.is(op.services.atproto_pds.type, 'AtprotoPersonalDataServer', 'with the right service type')
38
39 // A PLC op REPLACES the whole document state — it is not a patch. Carrying the rest forward is the
40 // whole correctness requirement, and getting it wrong silently drops keys.
41 t.alike(op.rotationKeys, id.signedOp.rotationKeys, 'rotation authority is carried forward untouched')
42 t.alike(op.verificationMethods, id.signedOp.verificationMethods, 'the signing key is carried forward')
43 t.ok(typeof op.prev === 'string' && op.prev.length > 0, 'it chains onto the genesis (CID verified by checkUpdateOp below)')
44})
45
46test('the op the holder produces is one the directory will accept', (t) => {
47 const id = p2pOnly()
48 const op = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
49 const chk = checkUpdateOp(op, id.signedOp)
50 t.ok(chk.ok, 'chains onto the genesis and is signed by a current rotation key: ' + (chk.error || 'ok'))
51})
52
53test('⛔ refuses a signer that is not a current rotation key', (t) => {
54 const id = p2pOnly()
55 // The SIGNING key. It authors commits and has zero PLC authority — device-node.js is explicit that a
56 // device never holds the root. Signing an update with it produces an op the directory rejects, and
57 // failing here beats discovering that against a permanent public log.
58 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
59 t.exception(() => attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: signing.privateKey }),
60 /rotation key/i, 'the #atproto signing key cannot rewrite the identity document')
61})
62
63test('⛔ refuses a no-op — a PLC append is permanent and public', (t) => {
64 const id = p2pOnly()
65 const once = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
66 t.exception(() => attachHosting({ prevSignedOp: once, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey }),
67 /already/i, 're-attaching identical hosting is refused rather than appended')
68})
69
70test('joining is REVERSIBLE — detaching returns it to P2P-only', (t) => {
71 const id = p2pOnly()
72 const joined = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
73 const left = detachHosting({ prevSignedOp: joined, signerPrivateKey: recovery().privateKey })
74
75 t.alike(left.alsoKnownAs, [], 'the handle is released')
76 t.alike(left.services, {}, 'and the PDS is no longer named')
77 t.ok(checkUpdateOp(left, joined).ok, 'and the directory would accept it')
78 t.alike(left.verificationMethods, id.signedOp.verificationMethods, 'the identity itself is unchanged — same keys, same DID')
79})
80
81test('other services survive both directions', (t) => {
82 // A community DID exports a feed generator and a labeler. Attaching a PDS must not silently drop them,
83 // and detaching the PDS must not take them with it.
84 const id = createSwarmIdentity(MNEMONIC, { feedGenEndpoint: 'https://feeds.example', labelerEndpoint: 'https://labels.example' })
85 const joined = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
86 t.is(joined.services.bsky_fg.endpoint, 'https://feeds.example', 'feed generator survives attach')
87 t.is(joined.services.atproto_labeler.endpoint, 'https://labels.example', 'labeler survives attach')
88
89 const left = detachHosting({ prevSignedOp: joined, signerPrivateKey: recovery().privateKey })
90 t.is(left.services.bsky_fg.endpoint, 'https://feeds.example', 'and survives detach')
91 t.absent(left.services.atproto_pds, 'while the PDS is gone')
92})
93
94test('moving to a different host is one op, not two', (t) => {
95 const id = p2pOnly()
96 const a = attachHosting({ prevSignedOp: id.signedOp, handle: HANDLE, pdsUrl: PDS, signerPrivateKey: recovery().privateKey })
97 const b = attachHosting({ prevSignedOp: a, handle: 'flaky.example.org', pdsUrl: 'https://pds.example.org', signerPrivateKey: recovery().privateKey })
98 t.alike(b.alsoKnownAs, ['at://flaky.example.org'], 'the new handle replaces the old')
99 t.is(b.services.atproto_pds.endpoint, 'https://pds.example.org', 'and the new endpoint replaces the old')
100 t.ok(checkUpdateOp(b, a).ok, 'chained onto the previous update, not the genesis')
101})