| … | … | @@ -1,11 +1,11 @@ |
| 1 | 1 | { |
| 2 | 2 | "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.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "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" | |
| 9 | 9 | }, |
| 10 | 10 | "dependencies": { |
| 11 | 11 | "atproto-identity": "file:../atproto-identity", |
| … | … | @@ -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. | |
| 14 | import '../lib/bare-globals.js' | |
| 15 | import process from 'bare-process' | |
| 16 | import fs from 'bare-fs' | |
| 17 | import Corestore from 'corestore' | |
| 18 | import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 19 | ||
| 20 | const HOME = process.env.HOME | |
| 21 | const SECRETS = HOME + '/.config/loser-club/secrets.json' | |
| 22 | const MEMBRANE = 'https://pds.7rnx.net' | |
| 23 | const RKEY = 'loser-club' | |
| 24 | ||
| 25 | function 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 } | |
| 26 | const repoPath = arg('--repo') | |
| 27 | const roomPath = arg('--room', (repoPath ? repoPath + '-room' : null)) | |
| 28 | const network = !!arg('--network', false) | |
| 29 | const createdAt = arg('--created-at', '2026-07-15T00:00:00.000Z') // deterministic unless overridden | |
| 30 | ||
| 31 | if (!fs.existsSync(SECRETS)) { console.error('no secrets at', SECRETS); process.exit(1) } | |
| 32 | if (!repoPath) { console.error('--repo <fresh-repo-path> is required (a NEW path — never the corrupted repo/repo-v3)'); process.exit(1) } | |
| 33 | const 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). | |
| 36 | const id = communityIdentity(s.rootMnemonic, { handle: s.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE }) | |
| 37 | if (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) } | |
| 38 | console.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). | |
| 43 | const roomStore = new Corestore(roomPath) | |
| 44 | const 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 | }) | |
| 49 | if (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). | |
| 52 | await publishFeedGenerator(c.repo, c.did, { rkey: RKEY, displayName: 'loser-club', description: 'a favela community feed', createdAt }) | |
| 53 | await publishLabelerService(c.repo, { labelValues: [], createdAt }) | |
| 54 | // re-assert the saved roster into this repo's linked room (the audience = wj62 + ashatron) | |
| 55 | for (const did of (s.members || [])) await c.room.addMember(did) | |
| 56 | ||
| 57 | // 4. Verify. | |
| 58 | const gen = await c.repo.getRecord('app.bsky.feed.generator', RKEY) | |
| 59 | const lab = await c.repo.getRecord('app.bsky.labeler.service', 'self') | |
| 60 | const roster = await c.room.members() | |
| 61 | console.log('✓ feed.generator:', gen && gen.$type, 'did=' + (gen && gen.did), 'rkey=' + RKEY) | |
| 62 | console.log('✓ labeler.service:', lab && lab.$type) | |
| 63 | console.log('✓ roster (audience):', roster.join(', ')) | |
| 64 | console.log('\nRECOVERED (repo at ' + repoPath + ', network=' + network + ').') | |
| 65 | console.log('feed uri: at://' + c.did + '/app.bsky.feed.generator/' + RKEY) | |
| 66 | console.log('NEXT (prod, needs go-ahead): serve this repo via a pgh app (NOT nohup); confirm the membrane') | |
| 67 | console.log(' frontedDids includes ' + c.did + '; POST requestCrawl → verify the feed resolves on bsky.') | |
| 68 | ||
| 69 | await c.sb.close(); await c.room.close() | |
| 70 | try { await roomStore.close() } catch {} | |
| 71 | process.exit(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 | |
| 8 | import '../lib/bare-globals.js' | |
| 9 | import Corestore from 'corestore' | |
| 10 | import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 11 | ||
| 12 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 13 | const stamp = Date.now() | |
| 14 | const tmp = (s) => `data/fn-recover-${s}-${stamp}` | |
| 15 | // The ONLY things a real recovery has = what secrets.json holds. (Fixed test mnemonics for hermeticity.) | |
| 16 | const 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 | } | |
| 22 | const MEMBRANE = 'https://pds.7rnx.net' | |
| 23 | const CTS = '2026-07-15T00:00:00.000Z' | |
| 24 | ||
| 25 | // ── (1) DETERMINISTIC identity recovery: same secrets → same DID + same signing key, every time ── | |
| 26 | const idA = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE }) | |
| 27 | const idB = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: MEMBRANE, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE }) | |
| 28 | assert(idA.did === idB.did, 'DID re-derives deterministically from the saved rootMnemonic (recovery yields the SAME did:plc)') | |
| 29 | assert(idA.didKey === idB.didKey, 'the #atproto did:key re-derives identically (the saved key still CONTROLS the published DID → no new PLC submit)') | |
| 30 | assert(/^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 | |
| 32 | const idWrong = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: 'https://other.example', feedGenEndpoint: 'https://other.example', labelerEndpoint: 'https://other.example' }) | |
| 33 | assert(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.) | |
| 38 | const roomStore = new Corestore(tmp('room')) | |
| 39 | const 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 | }) | |
| 44 | assert(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 | |
| 47 | assert((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)') | |
| 48 | const gen = await publishFeedGenerator(rec.repo, rec.did, { rkey: 'losertest', displayName: 'losertest', description: 'the recovered feed', createdAt: CTS }) | |
| 49 | assert(gen.did === rec.did, 'republished feed.generator.did = the recovered community DID') | |
| 50 | const genBack = await rec.repo.getRecord('app.bsky.feed.generator', 'losertest') | |
| 51 | assert(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)') | |
| 52 | await publishLabelerService(rec.repo, { labelValues: ['spam'], createdAt: CTS }) | |
| 53 | const labBack = await rec.repo.getRecord('app.bsky.labeler.service', 'self') | |
| 54 | assert(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. | |
| 59 | for (const did of SECRETS.members) await rec.room.addMember(did) | |
| 60 | const roster = (await rec.room.members()).sort() | |
| 61 | assert(roster.length === 2 && roster.includes(SECRETS.members[0]) && roster.includes(SECRETS.members[1]), 'roster re-asserted from saved members (wj62 + ashatron)') | |
| 62 | ||
| 63 | console.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.') | |
| 64 | await rec.sb.close(); await rec.room.close() | |
| 65 | try { await roomStore.close() } catch {} |