OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / ed610bd

a genesis can name nobody's infrastructure
buildGenesisOp always wrote a handle and a PDS endpoint, so you could not create a did:plc without first answering "whose server are you on?". That is what made minting undecidable: creating an identity and joining someone's service are different decisions, and the op forced them into one. Both are now optional, and omitting both is the P2P-only genesis (hyper-social #54 §8). Accepting an invite from atproto-bridge or ap-bridge is a LATER update op that adds the domain handle and the endpoint — so creation is free and joining is reversible. That ordering is not a preference: an update op must chain onto a genesis already in the log (checkUpdateOp wants prev == the head CID, signed by a head rotation key), so the genesis is submitted at creation and the invite extends it. The directory requires neither field — checkGenesisOp asks only for prev:null, rotationKeys, a verificationMethods.atproto and a self-signature. The shape was always valid; it just could not be built. ⛔ The two failure modes were not equally survivable. A missing pdsUrl put the VALUE `undefined` in the op and dag-cbor refused to encode it — loud, and safe. A missing handle produced the STRING 'at://undefined', which encodes fine and would have been appended to a global, permanent, public log. The test asserts on the silent one. Existing identities are untouched: given a handle and PDS the op is byte-identical, and the golden did:plc vectors and "same root + handle + pds reproduces the same DID" still pass. 61/61, 212/212. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ed610bd6889cfce77ea5ce9df54ec2ef729cb1c9
by plankatron <fleeky@gmail.com> · 2026-08-25T20:53:37.000Z

Changed files (2) +104 -3

lib/atproto-identity.js +20 -3
……@@ -67,9 +67,24 @@ export function verifyOp (unsigned, sigB64url, publicKey) {
6767
6868// Build an UNSIGNED did:plc genesis op. rotationKey defaults to the signing key
6969// (single-key setup); pass a distinct rotation didKey for key-separation.
70export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle, pdsUrl, labelDidKey = null, feedGenEndpoint = null, labelerEndpoint = null }) {
70// A HANDLE AND A PDS ARE OPTIONAL, and omitting both is the P2P-only genesis (hyper-social #54 §8):
71// an identity that names nobody's infrastructure. Creating a did:plc and joining someone's service are
72// different decisions, and requiring both at once is what made minting undecidable — you could not have
73// an identity without first answering "whose server are you on?". Accepting an invite from
74// atproto-bridge or ap-bridge is a LATER PLC UPDATE op that adds the domain handle and the endpoint, so
75// creation stays free and joining stays reversible.
76//
77// ⛔ THEY USED TO BE UNCONDITIONAL, and the two failure modes were not equally survivable. A missing
78// pdsUrl put the VALUE `undefined` in the op and dag-cbor refused to encode it — loud, and safe. A
79// missing handle produced the STRING 'at://undefined', which encodes perfectly and would have been
80// appended to a global, permanent, public log. The silent one is the one this guards.
81//
82// The directory requires neither: checkGenesisOp (lib/plc-directory.js) asks only for a plc_operation
83// with prev:null, rotationKeys, a verificationMethods.atproto and a self-signature.
84export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle = null, pdsUrl = null, labelDidKey = null, feedGenEndpoint = null, labelerEndpoint = null }) {
7185 const verificationMethods = { atproto: signingDidKey }
72 const services = { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } }
86 const services = {}
87 if (pdsUrl) services.atproto_pds = { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl }
7388 // Lens-export service entries (a self-custody feed-generator + labeler — e.g. a favela community DID).
7489 // The PLC op's `services`/`verificationMethods` KEYS become DID-doc fragments: services.bsky_fg → the
7590 // #bsky_fg service, verificationMethods.atproto_label → the #atproto_label VM. Endpoints point at where
……@@ -86,7 +101,9 @@ export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle, pdsUrl
86101 type: 'plc_operation',
87102 rotationKeys: rotationDidKeys && rotationDidKeys.length ? rotationDidKeys : [signingDidKey],
88103 verificationMethods,
89 alsoKnownAs: [`at://${handle}`],
104 // Empty, not absent: an identity with no handle claims no alias. The domain handle arrives with the
105 // invite, as an update op — it is not a thing you are born with.
106 alsoKnownAs: handle ? [`at://${handle}`] : [],
90107 services,
91108 prev: null
92109 }
test/p2p-only-genesis.test.js +84 -0
……@@ -0,0 +1,84 @@
1// A P2P-ONLY GENESIS — an identity that names nobody's infrastructure.
2//
3// Creating an identity and joining someone's infrastructure are different acts, and conflating them is
4// what made minting undecidable: the genesis op carried a handle and a PDS endpoint, so you could not
5// create a did:plc without first answering "whose server are you on?". The answer (hyper-social #54 §8)
6// is that you should not have to. Genesis is P2P-only — no handle, no PDS — and accepting an invite
7// from atproto-bridge or ap-bridge is a LATER PLC UPDATE op that adds the domain handle and the service
8// endpoint. Creation stays free; joining stays a separate, reversible act.
9//
10// ⛔ WHY THIS IS NOT COSMETIC. A PLC genesis is a permanent append to a global log. Before this,
11// buildGenesisOp unconditionally wrote `alsoKnownAs: ['at://' + handle]` and
12// `services.atproto_pds = { endpoint: pdsUrl }`, so minting without them produced the literal strings
13// `at://undefined` and an endpoint of `undefined` — for ever, unfixable, in public.
14//
15// The directory does not require either field: checkGenesisOp (lib/plc-directory.js, the local mirror of
16// the directory's §6.2 validation) asks only for a plc_operation with prev:null, non-empty rotationKeys,
17// a verificationMethods.atproto, and a signature by one of its own rotation keys. So the shape below is
18// valid — it just could not be built.
19
20import test from 'brittle'
21import { buildGenesisOp, signOp, createSwarmIdentity, deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from '../lib/atproto-identity.js'
22import { checkGenesisOp } from '../lib/plc-directory.js'
23
24const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
25const HANDLE = 'someone.example.com'
26const PDS = 'https://pds.example.com'
27
28test('a genesis with no handle and no PDS omits both fields entirely', (t) => {
29 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
30 const op = buildGenesisOp({ signingDidKey: signing.didKey })
31
32 t.alike(op.alsoKnownAs, [], 'alsoKnownAs is empty — no handle is claimed')
33 t.alike(op.services, {}, 'services is empty — no PDS is named')
34 t.is(op.verificationMethods.atproto, signing.didKey, 'the signing key is still there — this is a real identity')
35 t.is(op.prev, null, 'still a genesis')
36})
37
38test('⛔ never mints the string "undefined" into a permanent public log', (t) => {
39 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
40 const encoded = JSON.stringify(buildGenesisOp({ signingDidKey: signing.didKey }))
41 t.absent(encoded.includes('undefined'), 'no "undefined" anywhere in the op')
42 t.absent(encoded.includes('at://undefined'), 'specifically not the handle')
43})
44
45test('the P2P-only genesis is VALID — the directory asks for neither field', (t) => {
46 const recovery = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)
47 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
48 const unsigned = buildGenesisOp({ signingDidKey: signing.didKey, rotationDidKeys: [recovery.didKey] })
49 const signedOp = { ...unsigned, sig: signOp(unsigned, recovery.privateKey) }
50
51 const chk = checkGenesisOp(signedOp, null)
52 t.ok(chk.ok, 'passes the directory\'s genesis validation: ' + (chk.error || 'ok'))
53 t.ok(chk.did && chk.did.startsWith('did:plc:'), 'and yields a real did:plc — ' + chk.did)
54})
55
56test('createSwarmIdentity mints P2P-only when given neither', (t) => {
57 const id = createSwarmIdentity(MNEMONIC)
58 t.alike(id.signedOp.alsoKnownAs, [], 'no handle')
59 t.alike(id.signedOp.services, {}, 'no PDS')
60 t.ok(checkGenesisOp(id.signedOp, id.did).ok, 'and the op the phone would submit is valid')
61 // The custody split still holds — this is the property the whole revocation story rests on.
62 const recovery = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)
63 t.alike(id.signedOp.rotationKeys, [recovery.didKey], 'rotation authority is still the root recovery key alone')
64})
65
66// ── the invite is what adds them, later ──────────────────────────────────────────────────────────
67// Nothing here submits anything; this only fixes what the op CAN say. But the DID must differ from the
68// fronted one, or "P2P-only then join" would be indistinguishable from "join at birth" — and the whole
69// point is that the first is a decision you have not yet made.
70test('naming infrastructure at genesis produces a DIFFERENT identity', (t) => {
71 const free = createSwarmIdentity(MNEMONIC)
72 const fronted = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
73 t.not(free.did, fronted.did, 'the DID is the hash of the op, so the two are different identities')
74})
75
76// ── regression: the existing shape must be untouched ─────────────────────────────────────────────
77// Every identity already minted was built by this function. If passing a handle and PDS stopped
78// producing byte-identical ops, every existing DID would fail to re-derive from its own mnemonic.
79test('given a handle and PDS, the op is exactly what it always was', (t) => {
80 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
81 const op = buildGenesisOp({ signingDidKey: signing.didKey, handle: HANDLE, pdsUrl: PDS })
82 t.alike(op.alsoKnownAs, ['at://' + HANDLE], 'handle unchanged')
83 t.alike(op.services, { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: PDS } }, 'PDS unchanged')
84})