// community.js — mint + boot a community's CO-OWNED atproto DID and LINK it to a keyless CommunityRoom.
//
// The community DID is an hyper-proto-peer-style identity: an encrypted, attestation-gated StateBaseV3
// whose admins (devices attested to the community ROOT) co-own the repo; the lease-holder signs each
// commit with the SHARED #atproto key (RFC6979-deterministic → byte-identical → failover-safe). This is
// the SAME substrate hpp's bootSwarmDevice uses — key derivation is kept IDENTICAL (the NS() namespace +
// roomKey/roomSeed/encryptionKey), so a community repo is interoperable with the hpp/membrane stack.
//
// Phase 1 (spec/lens-export.md §8) uses StateBaseV3 + RepoBase directly (co-owned repo + linkage);
// membrane serving (bootSwarmDevice's control/membrane swarms) is a later phase.
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { join } from 'bare-path'
import { createSwarmIdentity } from 'atproto-identity'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { RepoBase } from 'atproto-room/lib/repo-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { CommunityRoom } from 'favela-community'
// MUST match hyper-proto-peer/lib/device-node.js NS() — the community repo shares hpp's key derivation.
const NS = (s) => b4a.from('atproto-base/v1/' + s)
function roomSecrets (did, signingKey) {
const sharedKey = typeof signingKey === 'string' ? b4a.from(signingKey, 'hex') : signingKey
return {
roomKey: crypto.hash([NS('room-key'), b4a.from(did)]),
roomSeed: crypto.hash([NS('room-seed'), b4a.from(sharedKey)]),
encryptionKey: crypto.hash([NS('enc'), b4a.from(sharedKey)]),
sharedKey
}
}
const hex = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'hex'))
// The community's atproto identity from its ROOT mnemonic (the founder holds it; the recovery key stays
// root-side — devices never receive it). feedGenEndpoint/labelerEndpoint declare the community DID as a
// self-custody feed-generator + labeler in its genesis op (#bsky_fg / #atproto_labeler services +
// #atproto_label VM — spec/lens-export.md §5), pointing at the membrane that proxies the XRPC. Returns
// { did, signedOp, signingKey, recoveryKey, didKey }.
export function communityIdentity (rootMnemonic, { handle, pdsUrl, feedGenEndpoint = null, labelerEndpoint = null } = {}) {
return createSwarmIdentity(rootMnemonic, { handle, pdsUrl, feedGenEndpoint, labelerEndpoint })
}
// ROOT-side: attest an admin device into the community (a keet root→device chain). The founder (root
// holder) runs this to admit a co-admin, then hands the admin its bundle { did, signingKey, rootPub,
// deviceProof } — NEVER the root mnemonic (admins can't self-recover / re-root; same as hpp provisionDevice).
export async function attestAdmin ({ rootMnemonic, deviceMnemonic, devicePublicKey } = {}) {
if (!rootMnemonic || (!deviceMnemonic && !devicePublicKey)) throw new Error('attestAdmin: rootMnemonic + (deviceMnemonic | devicePublicKey) required')
const devPub = devicePublicKey ? (typeof devicePublicKey === 'string' ? b4a.from(devicePublicKey, 'hex') : devicePublicKey)
: (await deviceKeetKeyPair(deviceMnemonic)).publicKey
const deviceProof = await attestDeviceChain({ rootMnemonic, devicePublicKey: devPub })
return { devicePublicKey: hex(devPub), deviceProof: b4a.toString(b4a.from(deviceProof), 'hex') }
}
// Open ONE admin's view of the co-owned community repo (StateBaseV3 + RepoBase). All admins share
// { did, signingKey, rootPub, members }; each holds its OWN deviceMnemonic + attestation proof.
export async function openCommunityRepo ({ storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members, swarmOpts = null, network = true } = {}) {
if (!storage || !did || !signingKey || !rootPub || !deviceMnemonic || !deviceProof || !members) throw new Error('openCommunityRepo: storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members required')
const deviceKp = await deviceKeetKeyPair(deviceMnemonic)
const proof = typeof deviceProof === 'string' ? b4a.from(deviceProof, 'hex') : deviceProof
const rootPubBuf = typeof rootPub === 'string' ? b4a.from(rootPub, 'hex') : rootPub
const { roomKey, roomSeed, encryptionKey } = roomSecrets(did, signingKey)
const sb = new StateBaseV3(join(storage, 'statebase'), roomKey, {
roomSeed,
encryptionKey,
network,
swarmOpts,
selfKey: deviceKp.publicKey,
attest: makeAttest(deviceKp, proof),
verifyAttest: makeVerifyAttest(rootPubBuf),
getMembers: async () => members
})
await sb.ready()
const repo = new RepoBase(sb, { did })
return { sb, repo, self: hex(deviceKp.publicKey) }
}
// FOUNDER: create a community end to end — mint the DID, boot the co-owned repo as the first admin,
// project the admin roster, and create the LINKED keyless CommunityRoom (meta:did = the community DID).
// adminDeviceKeys: pre-known co-admin device pubkeys (hex/Buffer) to seed into the roster now (attest +
// hand them their bundles separately via attestAdmin). Returns everything a caller needs to add records +
// admins. roomStore/repoStorage are separate (the room is plaintext/keyless; the repo is encrypted).
export async function createCommunity ({ rootMnemonic, founderDeviceMnemonic, adminDeviceKeys = [], name, roomStore, repoStorage, handle, pdsUrl = 'https://pds.7rnx.net', feedGenEndpoint = null, labelerEndpoint = null, swarmOpts = null, network = true } = {}) {
if (!rootMnemonic || !founderDeviceMnemonic || !roomStore || !repoStorage) throw new Error('createCommunity: rootMnemonic, founderDeviceMnemonic, roomStore, repoStorage required')
// Default the lens-export endpoints to the same membrane that fronts the PDS (it proxies the feed/label
// XRPC to favela-web — spec §4). A community DID thus declares its feed-generator + labeler at genesis.
const identity = communityIdentity(rootMnemonic, { handle, pdsUrl, feedGenEndpoint: feedGenEndpoint || pdsUrl, labelerEndpoint: labelerEndpoint || pdsUrl })
const signingKey = b4a.toString(identity.signingKey.privateKey, 'hex')
const rootPub = b4a.toString(await rootIdentityPublicKey(rootMnemonic), 'hex')
const founderKp = await deviceKeetKeyPair(founderDeviceMnemonic)
const founderProof = b4a.toString(b4a.from(await attestDeviceChain({ rootMnemonic, devicePublicKey: founderKp.publicKey })), 'hex')
const members = [
{ key: hex(founderKp.publicKey), role: 1 },
...adminDeviceKeys.map((k) => ({ key: hex(k), role: 1 }))
]
const { sb, repo, self } = await openCommunityRepo({ storage: repoStorage, did: identity.did, signingKey, rootPub, deviceMnemonic: founderDeviceMnemonic, deviceProof: founderProof, members, swarmOpts, network })
await sb.projectMembers(members)
const room = await CommunityRoom.create(roomStore, { name, did: identity.did })
return { did: identity.did, identity, signingKey, rootPub, members, sb, repo, room, self }
}
// A CO-ADMIN boots its view of an existing community from the bundle the founder handed it. Returns the
// { sb, repo } — poll sb.effectiveWritable() to know when the founder's roster/attestation has admitted it.
export async function bootCommunityAdmin ({ storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members, swarmOpts = null, network = true } = {}) {
return openCommunityRepo({ storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members, swarmOpts, network })
}
// RECOVER a community's co-owned REPO from its saved secrets (rootMnemonic + founderDeviceMnemonic), into
// FRESH storage — for when the repo stores were lost (e.g. an orphaned corestore fd-lock) but the secrets +
// the already-published DID survive. This is the founder's repo half of createCommunity WITHOUT the room:
// the CommunityRoom key is NOT mnemonic-derived (random at first mint, saved separately as secrets.roomKey),
// so recovery leaves the intact room store alone and REOPENS it in place at serve time — never recreates a
// divergent room. Deterministic: re-derives the SAME did + #atproto signing key, so the published PLC
// identity is reused with NO new submit. MUST pass the ORIGINAL endpoints — they are genesis-bound (part of
// the DID); a different serviceEndpoint yields a different DID. Returns { did, identity, signingKey, sb,
// repo, self } (no room). Caller publishes records + reopens the saved room separately.
export async function recoverCommunityRepo ({ rootMnemonic, founderDeviceMnemonic, repoStorage, handle, pdsUrl = 'https://pds.7rnx.net', feedGenEndpoint = null, labelerEndpoint = null, swarmOpts = null, network = true } = {}) {
if (!rootMnemonic || !founderDeviceMnemonic || !repoStorage) throw new Error('recoverCommunityRepo: rootMnemonic, founderDeviceMnemonic, repoStorage required')
const identity = communityIdentity(rootMnemonic, { handle, pdsUrl, feedGenEndpoint: feedGenEndpoint || pdsUrl, labelerEndpoint: labelerEndpoint || pdsUrl })
const signingKey = b4a.toString(identity.signingKey.privateKey, 'hex')
const rootPub = b4a.toString(await rootIdentityPublicKey(rootMnemonic), 'hex')
const founderKp = await deviceKeetKeyPair(founderDeviceMnemonic)
const founderProof = b4a.toString(b4a.from(await attestDeviceChain({ rootMnemonic, devicePublicKey: founderKp.publicKey })), 'hex')
const members = [{ key: hex(founderKp.publicKey), role: 1 }]
const { sb, repo, self } = await openCommunityRepo({ storage: repoStorage, did: identity.did, signingKey, rootPub, deviceMnemonic: founderDeviceMnemonic, deviceProof: founderProof, members, swarmOpts, network })
await sb.projectMembers(members)
return { did: identity.did, identity, signingKey, sb, repo, self }
}
// ── lens-export records (spec/lens-export.md §6.2) — written into the community DID's CO-OWNED repo,
// so they are co-signed by the lease-holding admin under the shared #atproto key. Any admitted admin's
// repo may write; the linearized view converges. rkeys are caller-chosen (the feedgen rkey = the feed's
// short name; the labeler is the reserved `self`). createdAt is caller-supplied for deterministic tests.
// app.bsky.feed.generator — the feed record, OWNED by this community/group (it lives in this repo, so its
// creator = the group account). The record's `did` field is the getFeedSkeleton SERVICE, which — like every
// real feed (Discover→did:web:discover.bsky.app, Artists→did:web:api.graze.social) — is a shared `did:web`
// (favela-web), NOT the group's own DID. Pass `service` (the did:web); the group stays the OWNER + name, the
// did:web is the invisible compute backend. (Falls back to communityDid only for legacy callers.) The feed
// resolves as "<displayName> by <group handle>", computed by the did:web. Returns the record.
export async function publishFeedGenerator (repo, communityDid, { rkey, displayName, service = null, description = '', avatar = null, createdAt = new Date().toISOString() } = {}) {
if (!repo || !communityDid || !rkey || !displayName) throw new Error('publishFeedGenerator: repo, communityDid, rkey, displayName required')
const serviceDid = service || communityDid // service SHOULD be a did:web (favela-web); communityDid is the legacy self-service fallback
const record = { $type: 'app.bsky.feed.generator', did: serviceDid, displayName, createdAt }
if (description) record.description = description
if (avatar) record.avatar = avatar
await repo.putRecord('app.bsky.feed.generator', String(rkey), record)
return record
}
// app.bsky.labeler.service (rkey 'self') — declares this community DID as a labeler + the label values it
// emits. Consumers verify labels against the DID's #atproto_label key (its PLC doc). Returns the record.
export async function publishLabelerService (repo, { labelValues = [], labelValueDefinitions = [], createdAt = new Date().toISOString() } = {}) {
if (!repo) throw new Error('publishLabelerService: repo required')
const record = { $type: 'app.bsky.labeler.service', policies: { labelValues }, createdAt }
if (labelValueDefinitions.length) record.policies.labelValueDefinitions = labelValueDefinitions
await repo.putRecord('app.bsky.labeler.service', 'self', record)
return record
}