OverviewFilesCommitsIssuesDocsReleasesPeers

favela-node commits / d8de213

favela-node 0.1.3: loser-club recovery (offline, from saved secrets)
loser-club's repo stores corrupted (nohup hard-kill → orphaned corestore fd-lock) but ~/.config/loser-club/secrets.json survived and the DID is already published on plc.directory. Recovery, proven offline: - test/recover.test.mjs: from the saved mnemonics ALONE, the DID + #atproto signing key re-derive DETERMINISTICALLY (so the published PLC identity is reusable — NO new submit); a fresh co-owned repo boots under that same DID and the feed.generator + labeler records republish + read back. Corrupted stores abandoned, never touched. Also asserts the endpoints are genesis-bound (recovery must use the original serviceEndpoint or the DID differs). - scripts/recover-loser-club.mjs: one-shot recovery tool (exits — serving is a pgh app's job, NOT nohup). Fail-fast guards that the re-derived DID matches the saved+published one before booting. network defaults FALSE. Verified against the REAL loser-club secrets (offline, scratchpad target): the tool re-derived did:plc:wqnwulql7ndcz6xmtvaovl6u + its #atproto key exactly, republished feed.generator (rkey=loser-club) + labeler.service, restored the roster (wj62 + ashatron). Real state untouched. The original mint never published the feed.generator record — recovery COMPLETES that (why the feed never resolved). NEXT (prod, needs go-ahead): serve the recovered repo via a pgh app + confirm membrane frontedDids + requestCrawl. Full suite (publish + community + recover) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
d8de213ffaba8122e757d08c11578ebd2ba5f5ca
by plankatron <fleeky@gmail.com> · 2026-07-15T22:33:27.000Z

Changed files (3) +139 -3

package.json +3 -3
……@@ -1,11 +1,11 @@
11{
22 "name": "favela-node",
3 "version": "0.1.2",
4 "description": "favela-node — 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.",
3 "version": "0.1.3",
4 "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",
77 "scripts": {
8 "test": "bare test/publish.test.mjs && bare test/community.test.mjs"
8 "test": "bare test/publish.test.mjs && bare test/community.test.mjs && bare test/recover.test.mjs"
99 },
1010 "dependencies": {
1111 "atproto-identity": "file:../atproto-identity",
scripts/recover-loser-club.mjs +71 -0
……@@ -0,0 +1,71 @@
1// Recover loser-club from its saved secrets into FRESH storage — the repo stores corrupted (nohup hard-kill
2// → orphaned corestore fd-lock), but ~/.config/loser-club/secrets.json survived and the DID is already
3// published (plc.directory) with #bsky_fg/#atproto_labeler → the membrane. This ONE-SHOT (it exits; serving
4// is a pgh app's job, NOT nohup) re-derives the SAME DID from the saved rootMnemonic, boots a FRESH co-owned
5// repo, and publishes the app.bsky.feed.generator + app.bsky.labeler.service records that make the feed
6// resolve. NO new PLC submit (the identity already exists + is correct). The corrupted stores are ABANDONED,
7// never touched.
8//
9// bare scripts/recover-loser-club.mjs --repo <fresh-repo-path> [--room <fresh-room-path>] [--network]
10//
11// Defaults are OFFLINE + throwaway so a dry run proves recovery without touching prod. For a REAL recovery,
12// point --repo at a NEW path (e.g. ~/.config/loser-club/repo-recovered — NOT the corrupted repo/repo-v3) and
13// serve it via a pgh app. network defaults FALSE (no swarm) unless --network.
14import '../lib/bare-globals.js'
15import process from 'bare-process'
16import fs from 'bare-fs'
17import Corestore from 'corestore'
18import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js'
19
20const HOME = process.env.HOME
21const SECRETS = HOME + '/.config/loser-club/secrets.json'
22const MEMBRANE = 'https://pds.7rnx.net'
23const RKEY = 'loser-club'
24
25function 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 }
26const repoPath = arg('--repo')
27const roomPath = arg('--room', (repoPath ? repoPath + '-room' : null))
28const network = !!arg('--network', false)
29const createdAt = arg('--created-at', '2026-07-15T00:00:00.000Z') // deterministic unless overridden
30
31if (!fs.existsSync(SECRETS)) { console.error('no secrets at', SECRETS); process.exit(1) }
32if (!repoPath) { console.error('--repo <fresh-repo-path> is required (a NEW path — never the corrupted repo/repo-v3)'); process.exit(1) }
33const s = JSON.parse(fs.readFileSync(SECRETS))
34
35// 1. Confirm the saved secrets re-derive the SAME published DID BEFORE booting anything (fail-fast guard).
36const id = communityIdentity(s.rootMnemonic, { handle: s.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE })
37if (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) }
38console.log('✓ recovered identity matches the published DID:', id.did, '(' + id.didKey + ')')
39
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({
45 rootMnemonic: s.rootMnemonic, founderDeviceMnemonic: s.founderDeviceMnemonic,
46 name: RKEY, handle: s.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
47 roomStore, repoStorage: repoPath, network
48})
49if (c.did !== s.did) { console.error('ABORT: booted repo DID mismatch'); process.exit(1) }
50
51// 3. Publish the lens-export records the original mint never wrote (why the feed never resolved).
52await publishFeedGenerator(c.repo, c.did, { rkey: RKEY, displayName: 'loser-club', description: 'a favela community feed', createdAt })
53await 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)
56
57// 4. Verify.
58const gen = await c.repo.getRecord('app.bsky.feed.generator', RKEY)
59const lab = await c.repo.getRecord('app.bsky.labeler.service', 'self')
60const roster = await c.room.members()
61console.log('✓ feed.generator:', gen && gen.$type, 'did=' + (gen && gen.did), 'rkey=' + RKEY)
62console.log('✓ labeler.service:', lab && lab.$type)
63console.log('✓ roster (audience):', roster.join(', '))
64console.log('\nRECOVERED (repo at ' + repoPath + ', network=' + network + ').')
65console.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.')
68
69await c.sb.close(); await c.room.close()
70try { await roomStore.close() } catch {}
71process.exit(0)
test/recover.test.mjs +65 -0
……@@ -0,0 +1,65 @@
1// favela-node RECOVERY — a community's hypercore stores can be LOST and the community fully rebuilt from
2// just its saved secrets (rootMnemonic + founderDeviceMnemonic + handle + endpoints). This is the loser-club
3// situation: the repo stores corrupted (nohup hard-kill → orphaned fd-lock) but ~/.config/loser-club/
4// secrets.json survived. Proves: (1) the DID + #atproto signing key re-derive DETERMINISTICALLY from the
5// secrets (so the already-published PLC identity is reusable — no new submit); (2) a FRESH co-owned repo
6// boots under that same DID and the feed.generator + labeler records republish + read back correctly.
7// The corrupted stores are ABANDONED, never touched. Run: bare test/recover.test.mjs
8import '../lib/bare-globals.js'
9import Corestore from 'corestore'
10import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js'
11
12const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
13const stamp = Date.now()
14const tmp = (s) => `data/fn-recover-${s}-${stamp}`
15// The ONLY things a real recovery has = what secrets.json holds. (Fixed test mnemonics for hermeticity.)
16const SECRETS = {
17 rootMnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
18 founderDeviceMnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow',
19 handle: 'losertest.7rnx.net',
20 members: ['did:plc:wj62ebbusln6gxo5azo6ogzo', 'did:plc:e2jevhuurrhlm36ufk7fklpg']
21}
22const MEMBRANE = 'https://pds.7rnx.net'
23const CTS = '2026-07-15T00:00:00.000Z'
24
25// ── (1) DETERMINISTIC identity recovery: same secrets → same DID + same signing key, every time ──
26const idA = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE })
27const idB = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE })
28assert(idA.did === idB.did, 'DID re-derives deterministically from the saved rootMnemonic (recovery yields the SAME did:plc)')
29assert(idA.didKey === idB.didKey, 'the #atproto did:key re-derives identically (the saved key still CONTROLS the published DID → no new PLC submit)')
30assert(/^did:plc:/.test(idA.did) && /^did:key:zQ3sh/.test(idA.didKey), 'recovered identity is a valid did:plc + secp256k1 did:key')
31// endpoints are part of the genesis op → part of the DID: recovering with DIFFERENT endpoints = a DIFFERENT DID
32const idWrong = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: 'https://other.example', feedGenEndpoint: 'https://other.example', labelerEndpoint: 'https://other.example' })
33assert(idWrong.did !== idA.did, 'recovery MUST use the original endpoints — a different serviceEndpoint yields a different DID (genesis-bound)')
34
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({
40 rootMnemonic: SECRETS.rootMnemonic, founderDeviceMnemonic: SECRETS.founderDeviceMnemonic,
41 name: 'losertest', handle: SECRETS.handle, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE,
42 roomStore, repoStorage: tmp('repo'), network: false
43})
44assert(rec.did === idA.did, 'the freshly-booted recovery repo is under the SAME recovered DID')
45
46// the fresh repo starts EMPTY (proof it is genuinely new storage, not the old one) — then we publish
47assert((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)')
48const gen = await publishFeedGenerator(rec.repo, rec.did, { rkey: 'losertest', displayName: 'losertest', description: 'the recovered feed', createdAt: CTS })
49assert(gen.did === rec.did, 'republished feed.generator.did = the recovered community DID')
50const genBack = await rec.repo.getRecord('app.bsky.feed.generator', 'losertest')
51assert(genBack && genBack.$type === 'app.bsky.feed.generator' && genBack.did === rec.did, 'feed.generator reads back from the recovered repo with the right DID (feed will resolve on bsky)')
52await publishLabelerService(rec.repo, { labelValues: ['spam'], createdAt: CTS })
53const labBack = await rec.repo.getRecord('app.bsky.labeler.service', 'self')
54assert(labBack && labBack.$type === 'app.bsky.labeler.service', 'labeler.service republished + read back from the recovered repo')
55
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 {}