| … | … | @@ -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 | |
| 20 | import test from 'brittle' |
| 21 | import { buildGenesisOp, signOp, createSwarmIdentity, deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from '../lib/atproto-identity.js' |
| 22 | import { checkGenesisOp } from '../lib/plc-directory.js' |
| 23 | |
| 24 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 25 | const HANDLE = 'someone.example.com' |
| 26 | const PDS = 'https://pds.example.com' |
| 27 | |
| 28 | test('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 | |
| 38 | test('⛔ 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 | |
| 45 | test('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 | |
| 56 | test('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. |
| 70 | test('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. |
| 79 | test('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 | }) |