| … | … | @@ -747,18 +747,52 @@ async function addPeer (did) { |
| 747 | 747 | return peer |
| 748 | 748 | } |
| 749 | 749 | |
| 750 | // ── THE SUBSCRIPTION LIST, AND WHY IT IS NOT CONFIGURATION ─────────────────────────────────────────── |
| 751 | // WHICH identities a node subscribes to was node-local: tabertron pinned FAVELA_ROSTER in apps.json |
| 752 | // while a handset derived its own from hosted + locally tracked DIDs. Same DID, two sources of truth, |
| 753 | // and they disagreed — ashatron was indexed on one and invisible on the other, which surfaced as a |
| 754 | // profile frozen two weeks in the past rather than as a missing subscription (hyper-social #53). |
| 755 | // |
| 756 | // A list is the fix that already exists: an app.bsky.graph.list in the owner's OWN repo, membership as |
| 757 | // listitems, resolved by lib/roster.js. It replicates with the identity, so every device hosting that |
| 758 | // DID converges on the same subscriptions with nothing configured anywhere. |
| 759 | // |
| 760 | // So look for one at a well-known rkey on the DID we host, when no FAVELA_LIST is set explicitly. No new |
| 761 | // substrate, no new env var to remember, and a second device inherits the roster by replication. |
| 762 | const SUBSCRIPTIONS_RKEY = 'subscriptions' |
| 763 | let listUri = LIST |
| 764 | if (!listUri) { |
| 765 | const selfDid = (HOSTED_DIDS || [])[0] || null |
| 766 | if (selfDid) { |
| 767 | const candidate = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}` |
| 768 | try { |
| 769 | const store = await getRecordStore(selfDid) |
| 770 | if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) { |
| 771 | listUri = candidate |
| 772 | console.log(JSON.stringify({ msg: 'roster: using the replicated subscriptions list', list: candidate })) |
| 773 | } |
| 774 | } catch (e) { |
| 775 | console.error(JSON.stringify({ msg: 'roster: could not read the subscriptions list (env roster still in force)', error: e.message })) |
| 776 | } |
| 777 | } |
| 778 | } |
| 779 | |
| 750 | 780 | // Resolve the roster: from a list (open the owner first to READ it, then use its member DIDs) or the |
| 751 | 781 | // env fallback. A list-resolve failure falls back to FAVELA_ROSTER rather than coming up empty. |
| 752 | 782 | let rosterDids = ROSTER |
| 753 | | if (LIST) { |
| 783 | if (listUri) { |
| 754 | 784 | try { |
| 755 | | const { owner } = parseListUri(LIST) |
| 785 | const { owner } = parseListUri(listUri) |
| 756 | 786 | const ownerPeer = await addPeer(owner) |
| 757 | 787 | if (!ownerPeer) throw new Error('could not open the list owner peer') |
| 758 | 788 | await ownerPeer.ready({ timeout: 60000 }) |
| 759 | | const r = await resolveRoster(ownerPeer, LIST) |
| 760 | | rosterDids = r.members |
| 761 | | console.log(`[favela] list "${r.name || LIST}" -> ${rosterDids.length} member(s)`) |
| 789 | const r = await resolveRoster(ownerPeer, listUri) |
| 790 | // ⛔ HOSTED DIDs ARE NOT OPTIONAL. `rosterDids = r.members` alone would drop this node's OWN identity |
| 791 | // out of the roster the moment a list resolves — it would stop indexing itself, which is invisible |
| 792 | // until every one of your own posts is missing. Hosted FIRST, because addPeer runs in roster order |
| 793 | // and apProjected() asks about ROSTER[0] specifically. |
| 794 | rosterDids = [...(HOSTED_DIDS || []), ...r.members].filter((d, i, a) => a.indexOf(d) === i) |
| 795 | console.log(`[favela] list "${r.name || listUri}" -> ${r.members.length} member(s), ${rosterDids.length} in roster with hosted`) |
| 762 | 796 | } catch (e) { console.error('[favela] list resolve failed:', e.message, '- falling back to FAVELA_ROSTER'); rosterDids = ROSTER } |
| 763 | 797 | } |
| 764 | 798 | for (const did of rosterDids) await addPeer(did) |
| … | … | @@ -869,6 +903,59 @@ const writer = WRITE_TRANSPORT |
| 869 | 903 | return () => Promise.reject(new Error('writes disabled: FAVELA_CONTROL_KEY is not set on this instance')) |
| 870 | 904 | } |
| 871 | 905 | }) |
| 906 | |
| 907 | // ── ONE-TIME: TURN A HAND-PINNED ROSTER INTO A REPLICATED ONE ──────────────────────────────────────── |
| 908 | // A node whose subscriptions came from FAVELA_ROSTER in apps.json writes that set into an |
| 909 | // app.bsky.graph.list in its own repo, ONCE. After that the list replicates with the identity, every |
| 910 | // device hosting the DID resolves the same roster (see the discovery above), and the env pin is dead |
| 911 | // weight that can be deleted. |
| 912 | // |
| 913 | // ⛔ ONLY FROM AN EXPLICIT PIN, AND ONLY WHERE WE SIGN. `ROSTER.length` is the pinned case by |
| 914 | // definition — a handset DERIVES its roster from hosted + tracked and so never migrates, which is also |
| 915 | // what stops two devices racing to author the same list. `writer.state().did` confirms this node signs |
| 916 | // for the identity whose repo we are about to write to. |
| 917 | // |
| 918 | // Runs AFTER `writer` exists: this used to sit beside the roster resolve 150 lines up, where `writer` |
| 919 | // is still in its temporal dead zone — a ReferenceError at boot on every node, caught before shipping |
| 920 | // rather than in a log. |
| 921 | // |
| 922 | // The migrated members are subscribed IMMEDIATELY rather than at the next boot, using the same runtime |
| 923 | // addPeer that following and viewing now use. A migration that needs a restart to take effect is the |
| 924 | // same defect this release is fixing. |
| 925 | if (!LIST && ROSTER.length && (HOSTED_DIDS || []).length) { |
| 926 | const selfDid = HOSTED_DIDS[0] |
| 927 | const uri = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}` |
| 928 | ;(async () => { |
| 929 | try { |
| 930 | const store = await getRecordStore(selfDid) |
| 931 | if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) return // already migrated; discovery picks it up |
| 932 | const st = await writer.state().catch(() => null) |
| 933 | if (!st || st.did !== selfDid) return // this node does not sign for the identity — not ours to write |
| 934 | const createdAt = new Date().toISOString() |
| 935 | await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, { |
| 936 | $type: LIST_COLLECTION, |
| 937 | purpose: CURATE, |
| 938 | name: 'subscriptions', |
| 939 | description: 'Identities this account subscribes to. Migrated from FAVELA_ROSTER.', |
| 940 | createdAt |
| 941 | }, { action: 'create' }) |
| 942 | const members = ROSTER.filter((d) => d !== selfDid) // hosted DIDs are subscribed regardless |
| 943 | for (const did of members) { |
| 944 | await writer.putRecord(LISTITEM_COLLECTION, tidNow(), { |
| 945 | $type: LISTITEM_COLLECTION, subject: did, list: uri, createdAt |
| 946 | }, { action: 'create' }) |
| 947 | addPeer(did).catch(() => {}) |
| 948 | } |
| 949 | console.log(JSON.stringify({ |
| 950 | msg: 'roster: MIGRATED FAVELA_ROSTER into a replicated list — every device hosting this DID now inherits it, and the env pin can be removed', |
| 951 | list: uri, members: members.length |
| 952 | })) |
| 953 | } catch (e) { |
| 954 | console.error(JSON.stringify({ msg: 'roster: list migration failed (the env roster still applies)', error: e.message })) |
| 955 | } |
| 956 | })() |
| 957 | } |
| 958 | |
| 872 | 959 | const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (d) => { b += d }); req.on('end', () => resolve(b)) }) |
| 873 | 960 | // BINARY-safe body reader. readBody above concatenates onto a STRING, which mangles any byte that is |
| 874 | 961 | // not valid UTF-8 — fine for JSON/form posts, silently corrupting for an image. Blob uploads MUST use |