| … | … | @@ -6,4 +6,4 @@ |
| 6 | 6 | // app.bsky.feed.generator + app.bsky.labeler.service records and its com.atproto.label labels. |
| 7 | 7 | // 2. the keyless CommunityRoom roster (favela-community) — the MEMBER audience favela-web reads. |
| 8 | 8 | // The room's MODS == the DID's admins (one attested group). This module composes them. |
| 9 | export { communityIdentity, attestAdmin, openCommunityRepo, createCommunity, bootCommunityAdmin, publishFeedGenerator, publishLabelerService } from './lib/community.js' | |
| 9 | export { communityIdentity, attestAdmin, openCommunityRepo, createCommunity, bootCommunityAdmin, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from './lib/community.js' |
| … | … | @@ -105,6 +105,28 @@ export async function bootCommunityAdmin ({ storage, did, signingKey, rootPub, d |
| 105 | 105 | return openCommunityRepo({ storage, did, signingKey, rootPub, deviceMnemonic, deviceProof, members, swarmOpts, network }) |
| 106 | 106 | } |
| 107 | 107 | |
| 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. | |
| 117 | export 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 | ||
| 108 | 130 | // ── lens-export records (spec/lens-export.md §6.2) — written into the community DID's CO-OWNED repo, |
| 109 | 131 | // so they are co-signed by the lease-holding admin under the shared #atproto key. Any admitted admin's |
| 110 | 132 | // repo may write; the linearized view converges. rkeys are caller-chosen (the feedgen rkey = the feed's |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-node", |
| 3 | "version": "0.1.3", | |
| 3 | "version": "0.1.4", | |
| 4 | 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", |
| … | … | @@ -14,8 +14,7 @@ |
| 14 | 14 | import '../lib/bare-globals.js' |
| 15 | 15 | import process from 'bare-process' |
| 16 | 16 | import fs from 'bare-fs' |
| 17 | import Corestore from 'corestore' | |
| 18 | import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 17 | import { communityIdentity, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 19 | 18 | |
| 20 | 19 | const HOME = process.env.HOME |
| 21 | 20 | const SECRETS = HOME + '/.config/loser-club/secrets.json' |
| … | … | @@ -24,7 +23,6 @@ const RKEY = 'loser-club' |
| 24 | 23 | |
| 25 | 24 | 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 | 25 | const repoPath = arg('--repo') |
| 27 | const roomPath = arg('--room', (repoPath ? repoPath + '-room' : null)) | |
| 28 | 26 | const network = !!arg('--network', false) |
| 29 | 27 | const createdAt = arg('--created-at', '2026-07-15T00:00:00.000Z') // deterministic unless overridden |
| 30 | 28 | |
| … | … | @@ -37,35 +35,33 @@ const id = communityIdentity(s.rootMnemonic, { handle: s.handle, pdsUrl: MEMBRAN |
| 37 | 35 | 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 | 36 | console.log('✓ recovered identity matches the published DID:', id.did, '(' + id.didKey + ')') |
| 39 | 37 | |
| 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({ | |
| 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. | |
| 42 | const c = await recoverCommunityRepo({ | |
| 45 | 43 | 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 | |
| 48 | 46 | }) |
| 49 | 47 | if (c.did !== s.did) { console.error('ABORT: booted repo DID mismatch'); process.exit(1) } |
| 50 | 48 | |
| 51 | 49 | // 3. Publish the lens-export records the original mint never wrote (why the feed never resolved). |
| 52 | 50 | await publishFeedGenerator(c.repo, c.did, { rkey: RKEY, displayName: 'loser-club', description: 'a favela community feed', createdAt }) |
| 53 | 51 | 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 | 52 | |
| 57 | // 4. Verify. | |
| 53 | // 4. Verify the records; report the roster + saved roomKey from secrets (the room is served separately). | |
| 58 | 54 | const gen = await c.repo.getRecord('app.bsky.feed.generator', RKEY) |
| 59 | 55 | const lab = await c.repo.getRecord('app.bsky.labeler.service', 'self') |
| 60 | const roster = await c.room.members() | |
| 61 | 56 | console.log('✓ feed.generator:', gen && gen.$type, 'did=' + (gen && gen.did), 'rkey=' + RKEY) |
| 62 | 57 | console.log('✓ labeler.service:', lab && lab.$type) |
| 63 | console.log('✓ roster (audience):', roster.join(', ')) | |
| 58 | console.log('✓ roster (from secrets, served via the saved room):', (s.members || []).join(', ')) | |
| 64 | 59 | console.log('\nRECOVERED (repo at ' + repoPath + ', network=' + network + ').') |
| 65 | 60 | 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.') | |
| 61 | console.log('saved roomKey (favela-web reads the roster keylessly from this):', s.roomKey) | |
| 62 | console.log('NEXT (prod, needs go-ahead): serve this repo + REOPEN the saved room (~/.config/loser-club/room)') | |
| 63 | console.log(' in place via a pgh app (NOT nohup); confirm membrane frontedDids includes ' + c.did + ';') | |
| 64 | console.log(' POST requestCrawl → verify the feed resolves on bsky.') | |
| 68 | 65 | |
| 69 | await c.sb.close(); await c.room.close() | |
| 70 | try { await roomStore.close() } catch {} | |
| 66 | await c.sb.close() | |
| 71 | 67 | process.exit(0) |
| … | … | @@ -6,8 +6,7 @@ |
| 6 | 6 | // boots under that same DID and the feed.generator + labeler records republish + read back correctly. |
| 7 | 7 | // The corrupted stores are ABANDONED, never touched. Run: bare test/recover.test.mjs |
| 8 | 8 | import '../lib/bare-globals.js' |
| 9 | import Corestore from 'corestore' | |
| 10 | import { communityIdentity, createCommunity, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 9 | import { communityIdentity, recoverCommunityRepo, publishFeedGenerator, publishLabelerService } from '../index.js' | |
| 11 | 10 | |
| 12 | 11 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } |
| 13 | 12 | const stamp = Date.now() |
| … | … | @@ -32,16 +31,16 @@ assert(/^did:plc:/.test(idA.did) && /^did:key:zQ3sh/.test(idA.didKey), 'recovere |
| 32 | 31 | const idWrong = communityIdentity(SECRETS.rootMnemonic, { handle: SECRETS.handle, pdsUrl: 'https://other.example', feedGenEndpoint: 'https://other.example', labelerEndpoint: 'https://other.example' }) |
| 33 | 32 | assert(idWrong.did !== idA.did, 'recovery MUST use the original endpoints — a different serviceEndpoint yields a different DID (genesis-bound)') |
| 34 | 33 | |
| 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({ | |
| 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.) | |
| 37 | const rec = await recoverCommunityRepo({ | |
| 40 | 38 | 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 | |
| 43 | 41 | }) |
| 44 | assert(rec.did === idA.did, 'the freshly-booted recovery repo is under the SAME recovered DID') | |
| 42 | assert(rec.did === idA.did, 'recoverCommunityRepo boots under the SAME recovered DID (repo only, no room)') | |
| 43 | assert(rec.room === undefined, 'recoverCommunityRepo returns NO room (the intact saved room is reopened separately, never recreated)') | |
| 45 | 44 | |
| 46 | 45 | // the fresh repo starts EMPTY (proof it is genuinely new storage, not the old one) — then we publish |
| 47 | 46 | 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)') |
| … | … | @@ -53,13 +52,5 @@ await publishLabelerService(rec.repo, { labelValues: ['spam'], createdAt: CTS }) |
| 53 | 52 | const labBack = await rec.repo.getRecord('app.bsky.labeler.service', 'self') |
| 54 | 53 | assert(labBack && labBack.$type === 'app.bsky.labeler.service', 'labeler.service republished + read back from the recovered repo') |
| 55 | 54 | |
| 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 {} | |
| 55 | 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 (repo-only; intact room untouched) + the feed.generator/labeler records republish. Corrupted stores abandoned.') | |
| 56 | await rec.sb.close() |