OverviewFilesCommitsIssuesDocsReleasesPeers

favela-node commits / 027f677

favela-node 0.1.4: recoverCommunityRepo — repo-only recovery (fix room defect)
The recovery script used createCommunity, which CREATES a fresh CommunityRoom with a NEW key — running it for real would scatter a divergent room into the config dir (the room key is random at mint, saved separately as secrets.roomKey, NOT mnemonic-derived). Fixed: - recoverCommunityRepo (lib/community.js, exported): the founder's repo half of createCommunity WITHOUT the room — re-derives the same DID + #atproto key from the saved mnemonics, boots the co-owned StateBaseV3 repo into fresh storage. The intact saved room is left alone and reopened in place at serve time. - recover-loser-club.mjs uses it: repo-only recovery + publish records; reports the saved roomKey/roster from secrets (never recreates the room). - test/recover.test.mjs exercises recoverCommunityRepo (asserts no room returned). Ran for real (network:false): materialized ~/.config/loser-club/repo-recovered (a NEW path — corrupted repo/repo-v3 and the saved room untouched) with the feed.generator + labeler.service records under did:plc:wqnwulql7ndcz6xmtvaovl6u. Full suite green. NEXT (prod, needs go-ahead): serve repo-recovered + reopen the saved room in place via a pgh app (not nohup); membrane frontedDids + requestCrawl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
027f6777f4ac4e2295c1a3adce40161af25e7945
by plankatron <fleeky@gmail.com> · 2026-07-15T23:16:45.000Z

Changed files (5) +50 -41

index.js +1 -1
……@@ -6,4 +6,4 @@
66// app.bsky.feed.generator + app.bsky.labeler.service records and its com.atproto.label labels.
77// 2. the keyless CommunityRoom roster (favela-community) — the MEMBER audience favela-web reads.
88// The room's MODS == the DID's admins (one attested group). This module composes them.
9export { communityIdentity, attestAdmin, openCommunityRepo, createCommunity, bootCommunityAdmin, publishFeedGenerator, publishLabelerService } from './lib/community.js'
9export { communityIdentity, attestAdmin, openCommunityRepo, createCommunity, bootCommunityAdmin, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from './lib/community.js'
lib/community.js +22 -0
……@@ -105,6 +105,28 @@ export async function bootCommunityAdmin ({ storage, did, signingKey, rootPub, d
105105 return openCommunityRepo({ storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members, swarmOpts, network })
106106}
107107
108// RECOVER a community's co-owned REPO from its saved secrets (rootMnemonic + founderDeviceMnemonic), into
109// FRESH storage — for when the repo stores were lost (e.g. an orphaned corestore fd-lock) but the secrets +
110// the already-published DID survive. This is the founder's repo half of createCommunity WITHOUT the room:
111// the CommunityRoom key is NOT mnemonic-derived (random at first mint, saved separately as secrets.roomKey),
112// so recovery leaves the intact room store alone and REOPENS it in place at serve time — never recreates a
113// divergent room. Deterministic: re-derives the SAME did + #atproto signing key, so the published PLC
114// identity is reused with NO new submit. MUST pass the ORIGINAL endpoints — they are genesis-bound (part of
115// the DID); a different serviceEndpoint yields a different DID. Returns { did, identity, signingKey, sb,
116// repo, self } (no room). Caller publishes records + reopens the saved room separately.
117export async function recoverCommunityRepo ({ rootMnemonic, founderDeviceMnemonic, repoStorage, handle, pdsUrl = 'https://pds.7rnx.net', feedGenEndpoint = null, labelerEndpoint = null, swarmOpts = null, network = true } = {}) {
118 if (!rootMnemonic || !founderDeviceMnemonic || !repoStorage) throw new Error('recoverCommunityRepo: rootMnemonic, founderDeviceMnemonic, repoStorage required')
119 const identity = communityIdentity(rootMnemonic, { handle, pdsUrl, feedGenEndpoint: feedGenEndpoint || pdsUrl, labelerEndpoint: labelerEndpoint || pdsUrl })
120 const signingKey = b4a.toString(identity.signingKey.privateKey, 'hex')
121 const rootPub = b4a.toString(await rootIdentityPublicKey(rootMnemonic), 'hex')
122 const founderKp = await deviceKeetKeyPair(founderDeviceMnemonic)
123 const founderProof = b4a.toString(b4a.from(await attestDeviceChain({ rootMnemonic, devicePublicKey: founderKp.publicKey })), 'hex')
124 const members = [{ key: hex(founderKp.publicKey), role: 1 }]
125 const { sb, repo, self } = await openCommunityRepo({ storage: repoStorage, did: identity.did, signingKey, rootPub, deviceMnemonic: founderDeviceMnemonic, deviceProof: founderProof, members, swarmOpts, network })
126 await sb.projectMembers(members)
127 return { did: identity.did, identity, signingKey, sb, repo, self }
128}
129
108130// ── lens-export records (spec/lens-export.md §6.2) — written into the community DID's CO-OWNED repo,
109131// so they are co-signed by the lease-holding admin under the shared #atproto key. Any admitted admin's
110132// repo may write; the linearized view converges. rkeys are caller-chosen (the feedgen rkey = the feed's
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-node",
3 "version": "0.1.3",
3 "version": "0.1.4",
44 "description": "favela-node \u2014 the full-node that composes a community's CO-OWNED atproto DID (hyper-proto-peer / atproto-room: StateBase admins + Lease + Signer/LabelRoom = the multiswarm-PDS pattern) with the keyless CommunityRoom roster (favela-community) and, later, the favela-web lens-export gateway. See favela-web/spec/lens-export.md.",
55 "type": "module",
66 "main": "index.js",
scripts/recover-loser-club.mjs +15 -19
……@@ -14,8 +14,7 @@
1414import '../lib/bare-globals.js'
1515import process from 'bare-process'
1616import fs from 'bare-fs'
17import Corestore from 'corestore'
18import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js'
17import { communityIdentity, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from '../index.js'
1918
2019const HOME = process.env.HOME
2120const SECRETS = HOME + '/.config/loser-club/secrets.json'
……@@ -24,7 +23,6 @@ const RKEY = 'loser-club'
2423
2524function arg (name, dflt = null) { const i = process.argv.indexOf(name); return i >= 0 ? (process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : true) : dflt }
2625const repoPath = arg('--repo')
27const roomPath = arg('--room', (repoPath ? repoPath + '-room' : null))
2826const network = !!arg('--network', false)
2927const createdAt = arg('--created-at', '2026-07-15T00:00:00.000Z') // deterministic unless overridden
3028
……@@ -37,35 +35,33 @@ const id = communityIdentity(s.rootMnemonic, { handle: s.handle, pdsUrl: MEMBRAN
3735if (id.did !== s.did) { console.error('ABORT: re-derived DID', id.did, '!== saved DID', s.did, '(wrong secrets/endpoints — recovery would create a DIFFERENT identity)'); process.exit(1) }
3836console.log('✓ recovered identity matches the published DID:', id.did, '(' + id.didKey + ')')
3937
40// 2. Boot a FRESH co-owned repo under that DID (createCommunity re-derives from the same mnemonics; we point
41// it at NEW storage). The room createCommunity makes here is a throwaway — the real roster room is the
42// saved secrets.roomKey (reopen that store separately; its key is NOT mnemonic-derived).
43const roomStore = new Corestore(roomPath)
44const c = await createCommunity({
38// 2. Boot a FRESH co-owned REPO under that DID (recoverCommunityRepo re-derives from the same mnemonics into
39// NEW storage). REPO ONLY — the roster room is the SAVED secrets.roomKey (an intact separate store); we do
40// NOT recreate it (its key is not mnemonic-derived → a new room would DIVERGE). Serving reopens the saved
41// room in place; recovery must not touch it.
42const c = await recoverCommunityRepo({
4543 rootMnemonic: s.rootMnemonic, founderDeviceMnemonic: s.founderDeviceMnemonic,
46 name: RKEY, handle: s.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
47 roomStore, repoStorage: repoPath, network
44 handle: s.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
45 repoStorage: repoPath, network
4846})
4947if (c.did !== s.did) { console.error('ABORT: booted repo DID mismatch'); process.exit(1) }
5048
5149// 3. Publish the lens-export records the original mint never wrote (why the feed never resolved).
5250await publishFeedGenerator(c.repo, c.did, { rkey: RKEY, displayName: 'loser-club', description: 'a favela community feed', createdAt })
5351await publishLabelerService(c.repo, { labelValues: [], createdAt })
54// re-assert the saved roster into this repo's linked room (the audience = wj62 + ashatron)
55for (const did of (s.members || [])) await c.room.addMember(did)
5652
57// 4. Verify.
53// 4. Verify the records; report the roster + saved roomKey from secrets (the room is served separately).
5854const gen = await c.repo.getRecord('app.bsky.feed.generator', RKEY)
5955const lab = await c.repo.getRecord('app.bsky.labeler.service', 'self')
60const roster = await c.room.members()
6156console.log('✓ feed.generator:', gen && gen.$type, 'did=' + (gen && gen.did), 'rkey=' + RKEY)
6257console.log('✓ labeler.service:', lab && lab.$type)
63console.log('✓ roster (audience):', roster.join(', '))
58console.log('✓ roster (from secrets, served via the saved room):', (s.members || []).join(', '))
6459console.log('\nRECOVERED (repo at ' + repoPath + ', network=' + network + ').')
6560console.log('feed uri: at://' + c.did + '/app.bsky.feed.generator/' + RKEY)
66console.log('NEXT (prod, needs go-ahead): serve this repo via a pgh app (NOT nohup); confirm the membrane')
67console.log(' frontedDids includes ' + c.did + '; POST requestCrawl → verify the feed resolves on bsky.')
61console.log('saved roomKey (favela-web reads the roster keylessly from this):', s.roomKey)
62console.log('NEXT (prod, needs go-ahead): serve this repo + REOPEN the saved room (~/.config/loser-club/room)')
63console.log(' in place via a pgh app (NOT nohup); confirm membrane frontedDids includes ' + c.did + ';')
64console.log(' POST requestCrawl → verify the feed resolves on bsky.')
6865
69await c.sb.close(); await c.room.close()
70try { await roomStore.close() } catch {}
66await c.sb.close()
7167process.exit(0)
test/recover.test.mjs +11 -20
……@@ -6,8 +6,7 @@
66// boots under that same DID and the feed.generator + labeler records republish + read back correctly.
77// The corrupted stores are ABANDONED, never touched. Run: bare test/recover.test.mjs
88import '../lib/bare-globals.js'
9import Corestore from 'corestore'
10import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js'
9import { communityIdentity, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from '../index.js'
1110
1211const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
1312const stamp = Date.now()
……@@ -32,16 +31,16 @@ assert(/^did:plc:/.test(idA.did) && /^did:key:zQ3sh/.test(idA.didKey), 'recovere
3231const idWrong = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: 'https://other.example', feedGenEndpoint: 'https://other.example', labelerEndpoint: 'https://other.example' })
3332assert(idWrong.did !== idA.did, 'recovery MUST use the original endpoints — a different serviceEndpoint yields a different DID (genesis-bound)')
3433
35// ── (2) boot a FRESH co-owned repo under that recovered DID + republish the lens-export records ──
36// (createCommunity re-derives the same identity from the same mnemonics; here it writes to BRAND-NEW storage,
37// standing in for the lost stores — the real recovery points repoStorage at a fresh path, never the corrupt one.)
38const roomStore = new Corestore(tmp('room'))
39const rec = await createCommunity({
34// ── (2) recoverCommunityRepo: boot a FRESH co-owned REPO under that recovered DID + republish records ──
35// (repo ONLY — the roster room is a separate saved store, reopened at serve time, NOT recreated here; its
36// key is not mnemonic-derived so recreating it would DIVERGE. Fresh storage stands in for the lost stores.)
37const rec = await recoverCommunityRepo({
4038 rootMnemonic: SECRETS.rootMnemonic, founderDeviceMnemonic: SECRETS.founderDeviceMnemonic,
41 name: 'losertest', handle: SECRETS.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
42 roomStore, repoStorage: tmp('repo'), network: false
39 handle: SECRETS.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
40 repoStorage: tmp('repo'), network: false
4341})
44assert(rec.did === idA.did, 'the freshly-booted recovery repo is under the SAME recovered DID')
42assert(rec.did === idA.did, 'recoverCommunityRepo boots under the SAME recovered DID (repo only, no room)')
43assert(rec.room === undefined, 'recoverCommunityRepo returns NO room (the intact saved room is reopened separately, never recreated)')
4544
4645// the fresh repo starts EMPTY (proof it is genuinely new storage, not the old one) — then we publish
4746assert((await rec.repo.getRecord('app.bsky.feed.generator', 'losertest')) === null, 'fresh recovery repo has no records yet (abandoned the corrupted store, did not reuse it)')
……@@ -53,13 +52,5 @@ await publishLabelerService(rec.repo, { labelValues: ['spam'], createdAt: CTS })
5352const labBack = await rec.repo.getRecord('app.bsky.labeler.service', 'self')
5453assert(labBack && labBack.$type === 'app.bsky.labeler.service', 'labeler.service republished + read back from the recovered repo')
5554
56// ── (3) roster re-asserted (audience = the saved members). NOTE: the CommunityRoom key is NOT derived from
57// the mnemonic (it was random at first mint, saved separately as secrets.roomKey). So real recovery REOPENS
58// the saved room store if it survived, else recreates + re-adds members and updates the favela-web roomKey.
59for (const did of SECRETS.members) await rec.room.addMember(did)
60const roster = (await rec.room.members()).sort()
61assert(roster.length === 2 && roster.includes(SECRETS.members[0]) && roster.includes(SECRETS.members[1]), 'roster re-asserted from saved members (wj62 + ashatron)')
62
63console.log('\nFAVELA-NODE RECOVERY OK: from saved secrets alone — same DID + signing key re-derive (no new PLC submit), a fresh co-owned repo boots + the feed.generator/labeler records republish. Corrupted stores abandoned, untouched.')
64await rec.sb.close(); await rec.room.close()
65try { await roomStore.close() } catch {}
55console.log('\nFAVELA-NODE RECOVERY OK: from saved secrets alone — same DID + signing key re-derive (no new PLC submit), a fresh co-owned repo boots (repo-only; intact room untouched) + the feed.generator/labeler records republish. Corrupted stores abandoned.')
56await rec.sb.close()