OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 1c71d46

atproto-identity 0.0.2: lens-export service entries in the did:plc genesis op
buildGenesisOp / createSwarmIdentity accept feedGenEndpoint / labelerEndpoint (+ an optional distinct labelDidKey) and emit, in the genesis plc_operation: services.bsky_fg → #bsky_fg (BskyFeedGenerator) services.atproto_labeler → #atproto_labeler (AtprotoLabeler) verificationMethods.atproto_label → #atproto_label (defaults to the #atproto signing key) So a self-custody DID (e.g. a favela community) declares itself a feed-generator + labeler at genesis, pointing at wherever the XRPC is served (the membrane). Purely additive: no extras → the original plain genesis (golden did:plc + swarm-identity tests unchanged, 7/7). Offline only — no PLC submit. Test: lens-export-services.test.js (entries present, distinct label key, backward compat). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1c71d468920aad1602c88992a2983122427ed5e8
by plankatron <fleeky@gmail.com> · 2026-07-15T14:37:21.000Z

Changed files (3) +66 -8

lib/atproto-identity.js +19 -7
……@@ -67,15 +67,27 @@ 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 }) {
70export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle, pdsUrl, labelDidKey = null, feedGenEndpoint = null, labelerEndpoint = null }) {
71 const verificationMethods = { atproto: signingDidKey }
72 const services = { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } }
73 // Lens-export service entries (a self-custody feed-generator + labeler — e.g. a favela community DID).
74 // The PLC op's `services`/`verificationMethods` KEYS become DID-doc fragments: services.bsky_fg → the
75 // #bsky_fg service, verificationMethods.atproto_label → the #atproto_label VM. Endpoints point at where
76 // the getFeedSkeleton / queryLabels+subscribeLabels XRPC is served (the membrane). #atproto_label
77 // defaults to the #atproto signing key (labels co-signed by the shared key) unless a distinct key is given.
78 if (feedGenEndpoint) services.bsky_fg = { type: 'BskyFeedGenerator', endpoint: feedGenEndpoint }
79 if (labelerEndpoint) {
80 services.atproto_labeler = { type: 'AtprotoLabeler', endpoint: labelerEndpoint }
81 verificationMethods.atproto_label = labelDidKey || signingDidKey
82 } else if (labelDidKey) {
83 verificationMethods.atproto_label = labelDidKey
84 }
7185 return {
7286 type: 'plc_operation',
7387 rotationKeys: rotationDidKeys && rotationDidKeys.length ? rotationDidKeys : [signingDidKey],
74 verificationMethods: { atproto: signingDidKey },
88 verificationMethods,
7589 alsoKnownAs: [`at://${handle}`],
76 services: {
77 atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl }
78 },
90 services,
7991 prev: null
8092 }
8193}
……@@ -109,10 +121,10 @@ export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) {
109121// recovery key (a genesis must be signed by one of its own rotationKeys). See
110122// atproto-base-REVOCATION.md §5. Returns { did, signedOp, signingKey, recoveryKey } — `signingKey`
111123// is distributed to attested devices; `recoveryKey` STAYS WITH THE ROOT.
112export function createSwarmIdentity (mnemonic, { handle, pdsUrl }) {
124export function createSwarmIdentity (mnemonic, { handle, pdsUrl, feedGenEndpoint = null, labelerEndpoint = null, labelDidKey = null } = {}) {
113125 const signingKey = deriveAtprotoKey(mnemonic, ATPROTO_SIGNING_PATH)
114126 const recoveryKey = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
115 const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl })
127 const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl, feedGenEndpoint, labelerEndpoint, labelDidKey })
116128 const sig = signOp(unsigned, recoveryKey.privateKey)
117129 const signedOp = { ...unsigned, sig }
118130 const did = didForCreateOp(signedOp)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-identity",
3 "version": "0.0.1",
3 "version": "0.0.2",
44 "description": "Seed-anchored atproto identity: one BIP39 mnemonic -> Ed25519 (Hypercore) + secp256k1 (did:plc). Offline did:plc genesis + verification.",
55 "type": "module",
66 "main": "lib/atproto-identity.js",
test/lens-export-services.test.js +46 -0
……@@ -0,0 +1,46 @@
1// Lens-export service entries in the did:plc genesis op — a self-custody feed-generator + labeler
2// (e.g. a favela community DID) declares #bsky_fg / #atproto_labeler services + a #atproto_label VM at
3// genesis. The PLC op's services/verificationMethods KEYS project to DID-doc fragments. Offline only —
4// no PLC submit. (favela-web/spec/lens-export.md §2/§5.)
5import test from 'brittle'
6import { createSwarmIdentity } from '../lib/atproto-identity.js'
7import { checkGenesisOp } from '../lib/plc-directory.js'
8
9const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
10const HANDLE = 'catlovers.7rnx.net'
11const PDS = 'https://pds.7rnx.net'
12const MEMBRANE = 'https://pds.7rnx.net'
13
14test('genesis with feed-gen + labeler service entries', (t) => {
15 const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE })
16 const op = id.signedOp
17
18 // the PDS service + #atproto VM are unchanged
19 t.is(op.services.atproto_pds.type, 'AtprotoPersonalDataServer', 'atproto_pds service preserved')
20 t.is(op.verificationMethods.atproto, id.signingKey.didKey, '#atproto VM = the signing key')
21
22 // NEW: feed generator + labeler services (keys → #bsky_fg / #atproto_labeler in the DID doc)
23 t.alike(op.services.bsky_fg, { type: 'BskyFeedGenerator', endpoint: MEMBRANE }, '#bsky_fg service points at the membrane')
24 t.alike(op.services.atproto_labeler, { type: 'AtprotoLabeler', endpoint: MEMBRANE }, '#atproto_labeler service points at the membrane')
25 t.is(op.verificationMethods.atproto_label, id.signingKey.didKey, '#atproto_label VM defaults to the shared #atproto signing key')
26
27 // the op is still a valid, self-consistent genesis (did derives from it)
28 t.is(checkGenesisOp(op, id.did).ok, true, 'genesis op still valid with the extra services')
29 t.ok(/^did:plc:/.test(id.did), 'did:plc derives')
30})
31
32test('a distinct #atproto_label key can be supplied', (t) => {
33 const other = createSwarmIdentity('legal winner thank year wave sausage worth useful legal winner thank yellow', { handle: 'x.7rnx.net', pdsUrl: PDS })
34 const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE, labelDidKey: other.signingKey.didKey })
35 t.is(id.signedOp.verificationMethods.atproto_label, other.signingKey.didKey, '#atproto_label VM = the supplied distinct key')
36})
37
38test('backward-compatible: no extras → plain genesis', (t) => {
39 const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
40 const op = id.signedOp
41 t.absent(op.services.bsky_fg, 'no #bsky_fg without an endpoint')
42 t.absent(op.services.atproto_labeler, 'no #atproto_labeler without an endpoint')
43 t.absent(op.verificationMethods.atproto_label, 'no #atproto_label without a labeler/key')
44 t.is(Object.keys(op.services).length, 1, 'only the atproto_pds service')
45 t.is(checkGenesisOp(op, id.did).ok, true, 'plain genesis still valid')
46})