| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795 |
- const pull = require('../server/node_modules/pull-stream');
- const crypto = require('crypto');
- const { getConfig } = require('../configs/config-manager.js');
- const logLimit = getConfig().ssbLogStream?.limit || 1000;
- const tribeLogLimit = Math.max(logLimit, 100000);
- const INVITE_CODE_BYTES = 16;
- const VALID_INVITE_MODES = ['strict', 'open'];
- module.exports = ({ cooler, tribeCrypto }) => {
- let ssb;
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
- let tribeIndex = null;
- let tribeIndexTs = 0;
- const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isLARP', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
- const arraysEqual = (a, b) => {
- const aa = Array.isArray(a) ? a : [];
- const bb = Array.isArray(b) ? b : [];
- if (aa.length !== bb.length) return false;
- for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false;
- return true;
- };
- const validMembershipDelta = (prevMembers, nextMembers, author) => {
- const prev = Array.isArray(prevMembers) ? prevMembers : [];
- const next = Array.isArray(nextMembers) ? nextMembers : [];
- const added = next.filter(m => !prev.includes(m));
- const removed = prev.filter(m => !next.includes(m));
- if (added.length === 0 && removed.length === 0) return true;
- if (added.length === 1 && removed.length === 0 && added[0] === author) return true;
- if (removed.length === 1 && added.length === 0 && removed[0] === author) return true;
- return false;
- };
- const validInvitesDelta = (prevInvites, nextInvites, author, rootAuthor) => {
- if (author === rootAuthor) return true;
- const prevCodes = new Set((prevInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
- const nextCodes = new Set((nextInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
- for (const c of nextCodes) if (!prevCodes.has(c)) return false;
- return true;
- };
- const structuralFieldsEqual = (prev, next) => {
- for (const f of STRUCTURAL_FIELDS) {
- const a = prev[f];
- const b = next[f];
- if (Array.isArray(a) || Array.isArray(b)) { if (!arraysEqual(a, b)) return false; continue; }
- if (a !== b && !(a == null && b == null)) return false;
- }
- return true;
- };
- const buildTribeIndex = async () => {
- if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
- const client = await openSsb();
- return new Promise((resolve, reject) => {
- pull(
- client.createLogStream({ limit: tribeLogLimit }),
- pull.collect((err, msgs) => {
- if (err) return reject(err);
- const tombstones = new Map();
- const tribeMsgs = new Map();
- for (const msg of msgs) {
- const k = msg.key;
- const c = msg.value?.content;
- if (!c) continue;
- const author = msg.value?.author;
- if (c.type === 'tombstone' && c.target) {
- tombstones.set(c.target, { author, ts: msg.value?.timestamp });
- continue;
- }
- if (c.type !== 'tribe') continue;
- tribeMsgs.set(k, { id: k, content: c, author, _ts: msg.value?.timestamp });
- }
- const tribes = new Map();
- const parent = new Map();
- const child = new Map();
- const rootByTip = new Map();
- for (const [k, entry] of tribeMsgs.entries()) {
- const c = entry.content;
- if (!c.replaces) {
- tribes.set(k, entry);
- rootByTip.set(k, k);
- }
- }
- let progress = true;
- while (progress) {
- progress = false;
- const candidatesByReplaces = new Map();
- for (const [k, entry] of tribeMsgs.entries()) {
- if (tribes.has(k)) continue;
- const replaces = entry.content.replaces;
- if (!replaces) continue;
- const parentEntry = tribes.get(replaces);
- if (!parentEntry) continue;
- if (child.has(replaces)) continue;
- const root = rootByTip.get(replaces);
- const rootEntry = tribes.get(root);
- const rootAuthor = rootEntry?.author;
- const isRootAuthor = entry.author === rootAuthor;
- const prevMembers = Array.isArray(parentEntry.content.members) ? parentEntry.content.members : [];
- if (!isRootAuthor) {
- if (!prevMembers.includes(entry.author) && !(entry.content.members || []).includes(entry.author)) continue;
- if (!validMembershipDelta(prevMembers, entry.content.members, entry.author)) continue;
- if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
- if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
- }
- if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
- candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root });
- }
- for (const [replaces, candidates] of candidatesByReplaces.entries()) {
- if (child.has(replaces)) continue;
- let winner = candidates[0];
- for (let i = 1; i < candidates.length; i++) {
- const c = candidates[i];
- if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; }
- if (winner.isRootAuthor && !c.isRootAuthor) continue;
- const wt = winner.entry._ts || 0;
- const ct = c.entry._ts || 0;
- if (ct < wt) winner = c;
- else if (ct === wt && c.k < winner.k) winner = c;
- }
- parent.set(winner.k, replaces);
- child.set(replaces, winner.k);
- tribes.set(winner.k, winner.entry);
- rootByTip.set(winner.k, winner.root);
- progress = true;
- }
- }
- const tombstoned = new Set();
- for (const [target, t] of tombstones.entries()) {
- const tribeEntry = tribes.get(target);
- if (!tribeEntry) continue;
- const root = rootByTip.get(target);
- const rootAuthor = tribes.get(root)?.author;
- if (t.author === rootAuthor) tombstoned.add(target);
- }
- const rootOf = (id) => rootByTip.get(id) || id;
- const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
- const tipByRoot = new Map();
- for (const k of tribes.keys()) {
- const root = rootOf(k);
- const tip = tipOf(root);
- tipByRoot.set(root, tip);
- }
- const effectivelyTombstoned = new Set(tombstoned);
- let cascadeProgress = true;
- while (cascadeProgress) {
- cascadeProgress = false;
- for (const k of tribes.keys()) {
- if (effectivelyTombstoned.has(k)) continue;
- const root = rootOf(k);
- if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascadeProgress = true; continue; }
- const entry = tribes.get(k);
- const pid = entry?.content?.parentTribeId;
- if (!pid) continue;
- const parentRoot = rootOf(pid);
- if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) {
- effectivelyTombstoned.add(k);
- cascadeProgress = true;
- }
- }
- }
- tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip };
- tribeIndexTs = Date.now();
- resolve(tribeIndex);
- })
- );
- });
- };
- return {
- type: 'tribe',
- async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
- if (!VALID_INVITE_MODES.includes(inviteMode)) {
- throw new Error('Invalid invite mode. Must be "strict" or "open"');
- }
- const ssb = await openSsb();
- const userId = ssb.id;
- let blobId = null;
- if (image) {
- blobId = String(image).trim() || null;
- }
- const tags = Array.isArray(tagsRaw)
- ? tagsRaw.filter(Boolean)
- : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
- const content = {
- type: 'tribe',
- title,
- description,
- image: blobId,
- location,
- tags,
- isLARP: Boolean(isLARP),
- isAnonymous: Boolean(isAnonymous),
- members: [userId],
- invites: [],
- inviteMode,
- status: status || 'OPEN',
- parentTribeId: parentTribeId || null,
- mapUrl: String(mapUrl || '').trim(),
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- author: userId,
- };
- const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
- if (tribeCrypto) {
- const tribeKey = tribeCrypto.generateTribeKey();
- tribeCrypto.setKey(result.key, tribeKey, 1);
- }
- tribeIndex = null;
- return result;
- },
- async generateInvite(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribe = await this.getTribeById(tribeId);
- if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
- throw new Error('Only the author can generate invites in strict mode');
- }
- if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
- throw new Error('Only tribe members can generate invites in open mode');
- }
- const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
- let invite = code;
- if (tribeCrypto) {
- const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
- if (Array.isArray(ancestryIds) && ancestryIds.length) {
- const salt = tribeCrypto.generateInviteSalt();
- const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code, salt);
- if (ekChain) {
- invite = {
- codeHash: tribeCrypto.hashInviteCode(code, salt),
- ekChain,
- salt,
- gen: tribeCrypto.getGen(ancestryIds[0])
- };
- }
- }
- }
- const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
- await this.updateTribeInvites(tribeId, invites);
- return code;
- },
- async updateTribeInvites(tribeId, invites) {
- return this.updateTribeById(tribeId, { invites });
- },
- async leaveTribe(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) throw new Error('Tribe not found');
- if (tribe.author === userId) {
- throw new Error('Tribe author cannot leave their own tribe');
- }
- const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
- const idx = members.indexOf(userId);
- if (idx === -1) throw new Error('User is not a member of this tribe');
- members.splice(idx, 1);
- await this.updateTribeById(tribeId, { members });
- await this.rotateTribeKey(tribeId, members);
- },
- async joinByInvite(code) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribes = await this.listAll();
- let matchedTribe = null;
- let matchedInvite = null;
- for (const t of tribes) {
- if (!t.invites) continue;
- for (const inv of t.invites) {
- if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) {
- matchedTribe = t; matchedInvite = inv; break;
- }
- }
- if (matchedTribe) break;
- }
- if (!matchedTribe) throw new Error('Invalid or expired invite code');
- if (matchedTribe.members.includes(userId)) {
- throw new Error('Already a member of this tribe');
- }
- let storedTribeKey = null;
- let storedGen = 1;
- let storedRootId = null;
- if (tribeCrypto && typeof matchedInvite === 'object') {
- const salt = matchedInvite.salt;
- if (matchedInvite.ekChain) {
- const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt);
- if (Array.isArray(chain) && chain.length) {
- for (const entry of chain) {
- if (Array.isArray(entry.keys) && entry.keys.length) {
- tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
- } else if (entry.key) {
- tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
- }
- }
- storedRootId = chain[0].rootId;
- storedTribeKey = chain[0].key;
- storedGen = chain[0].gen || 1;
- }
- } else if (matchedInvite.ek) {
- storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt);
- storedRootId = await this.getRootId(matchedTribe.id);
- storedGen = matchedInvite.gen || 1;
- tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
- }
- }
- const members = [...matchedTribe.members, userId];
- const invites = matchedTribe.invites.filter(inv => {
- if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(inv, code);
- if (typeof inv === 'string') return inv !== code;
- return inv && inv.code !== code;
- });
- await this.updateTribeById(matchedTribe.id, { members, invites });
- if (tribeCrypto && storedTribeKey && storedRootId) {
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const memberKeys = {};
- try { memberKeys[userId] = tribeCrypto.boxKeyForMember(storedTribeKey, userId, ssbKeys); } catch (_) {}
- if (matchedTribe.author && matchedTribe.author !== userId) {
- try { memberKeys[matchedTribe.author] = tribeCrypto.boxKeyForMember(storedTribeKey, matchedTribe.author, ssbKeys); } catch (_) {}
- }
- if (Object.keys(memberKeys).length) {
- await new Promise((resolve) => {
- ssb.publish({ type: 'tribe-keys', tribeId: storedRootId, generation: storedGen, memberKeys }, () => resolve());
- });
- }
- }
- await this.ensureFollowTribeMembers(matchedTribe.id).catch(() => {});
- return matchedTribe.id;
- },
- async deleteTribeById(tribeId) {
- await this.publishTombstone(tribeId);
- },
- async updateTribeMembers(tribeId, members) {
- const tribe = await this.getTribeById(tribeId);
- const oldMembers = tribe.members || [];
- await this.updateTribeById(tribeId, { members });
- const removed = oldMembers.filter(m => !members.includes(m));
- const added = members.filter(m => !oldMembers.includes(m));
- if (removed.length > 0) {
- await this.rotateTribeKey(tribeId, members);
- } else if (added.length > 0) {
- await this.distributeTribeKey(tribeId, added);
- }
- },
- async distributeTribeKey(tribeId, toMembers) {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const rootId = await this.getRootId(tribeId);
- const currentKey = tribeCrypto.getKey(rootId);
- if (!currentKey) return;
- const allKeys = tribeCrypto.getKeys(rootId);
- const gen = tribeCrypto.getGen(rootId);
- const payload = JSON.stringify({ keys: allKeys, gen });
- const memberKeys = {};
- const memberKeysFull = {};
- for (const memberId of toMembers) {
- try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
- try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(payload, memberId, ssbKeys); } catch (_) {}
- }
- if (!Object.keys(memberKeys).length && !Object.keys(memberKeysFull).length) return;
- await new Promise((resolve, reject) => {
- ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys, memberKeysFull }, (err, res) => err ? reject(err) : resolve(res));
- });
- await this.ensureFollowTribeMembers(tribeId).catch(() => {});
- },
- async ensureTribeKeyDistribution(tribeId) {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribe = await this.getTribeById(tribeId).catch(() => null);
- if (!tribe || tribe.author !== userId) return;
- const rootId = await this.getRootId(tribeId);
- const currentKey = tribeCrypto.getKey(rootId);
- if (!currentKey) return;
- const gen = tribeCrypto.getGen(rootId);
- const msgs = await new Promise((resolve, reject) => {
- pull(ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
- });
- const distributed = new Set();
- for (const m of msgs) {
- const c = m.value?.content;
- if (!c || c.type !== 'tribe-keys') continue;
- if (c.tribeId !== rootId) continue;
- if ((c.generation || 0) < gen) continue;
- for (const mid of Object.keys(c.memberKeys || {})) distributed.add(mid);
- }
- const members = Array.isArray(tribe.members) ? tribe.members : [];
- const missing = members.filter(m => m !== userId && !distributed.has(m));
- if (missing.length > 0) await this.distributeTribeKey(tribeId, missing);
- },
- async publishUpdatedTribe(tribeId, updatedTribe) {
- const ssb = await openSsb();
- const updatedTribeData = {
- type: 'tribe',
- replaces: updatedTribe.replaces || tribeId,
- title: updatedTribe.title,
- description: updatedTribe.description,
- image: updatedTribe.image,
- location: updatedTribe.location,
- tags: updatedTribe.tags,
- isLARP: updatedTribe.isLARP,
- isAnonymous: updatedTribe.isAnonymous,
- members: updatedTribe.members,
- invites: updatedTribe.invites,
- inviteMode: updatedTribe.inviteMode,
- status: updatedTribe.status || 'OPEN',
- parentTribeId: updatedTribe.parentTribeId || null,
- mapUrl: updatedTribe.mapUrl || "",
- createdAt: updatedTribe.createdAt,
- updatedAt: new Date().toISOString(),
- author: updatedTribe.author,
- };
- const result = await new Promise((resolve, reject) => {
- ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
- });
- tribeIndex = null;
- return result;
- },
- async getTribeById(tribeId) {
- const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex();
- let latestId = tribeId;
- while (child.has(latestId)) latestId = child.get(latestId);
- if (tombstoned.has(latestId) || effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found');
- const tribe = tribes.get(latestId);
- if (!tribe) throw new Error('Tribe not found');
- return {
- id: tribe.id,
- title: tribe.content.title,
- description: tribe.content.description,
- image: tribe.content.image || null,
- location: tribe.content.location,
- tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
- isLARP: !!tribe.content.isLARP,
- isAnonymous: tribe.content.isAnonymous,
- members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
- invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
- inviteMode: tribe.content.inviteMode || 'strict',
- status: tribe.content.status || 'OPEN',
- parentTribeId: tribe.content.parentTribeId || null,
- mapUrl: tribe.content.mapUrl || "",
- createdAt: tribe.content.createdAt,
- updatedAt: tribe.content.updatedAt,
- author: tribe.content.author,
- };
- },
- async listAll() {
- const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
- const resolveParent = (pid) => {
- if (!pid) return null;
- const root = rootByTip.get(pid) || pid;
- return tipByRoot.get(root) || pid;
- };
- const items = [];
- for (const [root, tip] of tipByRoot) {
- if (tombstoned.has(root) || tombstoned.has(tip)) continue;
- if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue;
- const entry = tribes.get(tip);
- if (!entry) continue;
- const c = entry.content;
- items.push({
- id: tip,
- title: c.title,
- description: c.description,
- image: c.image || null,
- location: c.location,
- tags: Array.isArray(c.tags) ? c.tags : [],
- isLARP: !!c.isLARP,
- isAnonymous: c.isAnonymous !== false,
- members: Array.isArray(c.members) ? c.members : [],
- invites: Array.isArray(c.invites) ? c.invites : [],
- inviteMode: c.inviteMode || 'strict',
- status: c.status || 'OPEN',
- parentTribeId: resolveParent(c.parentTribeId),
- mapUrl: c.mapUrl || "",
- createdAt: c.createdAt,
- updatedAt: c.updatedAt,
- author: c.author,
- _ts: entry._ts
- });
- }
- return items;
- },
- async getChainIds(tribeId) {
- const { parent, child } = await buildTribeIndex();
- let root = tribeId;
- while (parent.has(root)) root = parent.get(root);
- const ids = [root];
- let cur = root;
- while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
- return ids;
- },
- async getRootId(tribeId) {
- const { parent } = await buildTribeIndex();
- let root = tribeId;
- while (parent.has(root)) root = parent.get(root);
- return root;
- },
- async getAncestryChain(tribeId) {
- const rootId = await this.getRootId(tribeId);
- const tribe = await this.getTribeById(tribeId);
- const chain = [rootId];
- let currentTribe = tribe;
- while (currentTribe.parentTribeId) {
- const parentRootId = await this.getRootId(currentTribe.parentTribeId);
- chain.push(parentRootId);
- try {
- currentTribe = await this.getTribeById(currentTribe.parentTribeId);
- } catch (e) {
- break;
- }
- }
- return chain;
- },
- async rotateTribeKey(tribeId, remainingMembers) {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const rootId = await this.getRootId(tribeId);
- const oldKey = tribeCrypto.getKey(rootId);
- if (!oldKey) return;
- const newKey = tribeCrypto.generateTribeKey();
- const newGen = tribeCrypto.addNewKey(rootId, newKey);
- const allKeys = tribeCrypto.getKeys(rootId);
- const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen });
- const memberKeys = {};
- const memberKeysFull = {};
- for (const memberId of remainingMembers) {
- try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); } catch (_) {}
- try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(fullPayload, memberId, ssbKeys); } catch (_) {}
- }
- const entries = Object.entries(memberKeys);
- const BATCH_SIZE = 20;
- for (let i = 0; i < entries.length; i += BATCH_SIZE) {
- const batchSingle = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
- const batchFull = {};
- for (const id of Object.keys(batchSingle)) {
- if (memberKeysFull[id]) batchFull[id] = memberKeysFull[id];
- }
- await new Promise((resolve, reject) => {
- ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batchSingle, memberKeysFull: batchFull },
- (err, res) => err ? reject(err) : resolve(res));
- });
- }
- const tribe = await this.getTribeById(tribeId);
- if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
- const survivingInvites = tribe.invites.map(inv => {
- if (typeof inv === 'string') return inv;
- if (!inv || typeof inv !== 'object') return inv;
- const next = { ...inv, gen: newGen };
- delete next.ekChain;
- delete next.ek;
- return next;
- });
- await this.updateTribeInvites(tribeId, survivingInvites);
- }
- },
- async processIncomingKeys() {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const config = require('../server/ssb_config');
- const msgs = await new Promise((resolve, reject) => {
- pull(
- ssb.createLogStream({ limit: tribeLogLimit }),
- pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
- );
- });
- const byTribe = new Map();
- for (const m of msgs) {
- const c = m.value?.content;
- if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue;
- const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id];
- const singleEntry = c.memberKeys && c.memberKeys[ssb.id];
- if (!fullEntry && !singleEntry) continue;
- const list = byTribe.get(c.tribeId) || [];
- list.push({ generation: c.generation || 0, fullEntry, singleEntry });
- byTribe.set(c.tribeId, list);
- }
- for (const [tribeId, entries] of byTribe.entries()) {
- entries.sort((a, b) => b.generation - a.generation);
- const top = entries[0];
- const knownGen = tribeCrypto.getGen(tribeId);
- if (top.fullEntry) {
- try {
- const text = tribeCrypto.unboxKeyFromMember(top.fullEntry, config.keys, ssbKeys);
- const parsed = text ? JSON.parse(text) : null;
- if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) {
- tribeCrypto.mergeKeys(tribeId, parsed.keys, parsed.gen || top.generation || knownGen);
- continue;
- }
- } catch (_) {}
- }
- if (top.singleEntry && top.generation > knownGen) {
- const newKey = tribeCrypto.unboxKeyFromMember(top.singleEntry, config.keys, ssbKeys);
- if (newKey) tribeCrypto.addNewKey(tribeId, newKey);
- }
- }
- },
- async ensureFollowTribeMembers(tribeId) {
- const ssb = await openSsb();
- const me = ssb.id;
- let tribe;
- try { tribe = await this.getTribeById(tribeId); } catch { return; }
- const rootId = await this.getRootId(tribeId).catch(() => tribeId);
- const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
- const tribeRootSet = new Set([rootId]);
- const tribeChainSet = new Set(tribeChainIds);
- tribeChainSet.add(tribeId);
- const discovered = new Set();
- const myFollows = new Map();
- await new Promise((resolve, reject) => {
- pull(
- ssb.createLogStream({ limit: tribeLogLimit }),
- pull.collect((err, msgs) => {
- if (err) return reject(err);
- for (const m of msgs) {
- const v = m.value;
- if (!v) continue;
- const c = v.content;
- if (!c) continue;
- if (v.author === me && c.type === 'contact' && c.contact && typeof c.following === 'boolean') {
- myFollows.set(c.contact, c.following);
- continue;
- }
- if (c.type === 'tribe-keys' && c.tribeId && tribeRootSet.has(c.tribeId) && c.memberKeys && typeof c.memberKeys === 'object') {
- for (const fid of Object.keys(c.memberKeys)) discovered.add(fid);
- if (v.author) discovered.add(v.author);
- continue;
- }
- if (c.type === 'tribe' && Array.isArray(c.members)) {
- if (tribeChainSet.has(m.key) || tribeChainSet.has(c.replaces || '')) {
- for (const fid of c.members) if (fid) discovered.add(fid);
- if (c.author) discovered.add(c.author);
- }
- }
- }
- resolve();
- })
- );
- });
- const baseMembers = Array.isArray(tribe.members) ? tribe.members : [];
- for (const fid of baseMembers) discovered.add(fid);
- if (tribe.author) discovered.add(tribe.author);
- discovered.delete(me);
- const members = [...discovered].filter(Boolean);
- if (!members.length) return;
- for (const memberId of members) {
- if (myFollows.get(memberId) === true) continue;
- await new Promise((resolve) => {
- ssb.publish({ type: 'contact', contact: memberId, following: true }, () => resolve());
- });
- }
- },
-
- async updateTribeById(tribeId, updatedContent) {
- const ssb = await openSsb();
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) throw new Error('Tribe not found');
- const updatedTribe = {
- type: 'tribe',
- ...tribe,
- ...updatedContent,
- replaces: tribeId,
- updatedAt: new Date().toISOString()
- };
- return this.publishUpdatedTribe(tribeId, updatedTribe);
- },
- async publishTombstone(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tombstone = {
- type: 'tombstone',
- target: tribeId,
- deletedAt: new Date().toISOString(),
- author: userId
- };
- await new Promise((resolve, reject) => {
- ssb.publish(tombstone, (err) => {
- if (err) return reject(err);
- resolve();
- });
- });
- tribeIndex = null;
- },
- async listSubTribes(parentId, userId) {
- const idx = await buildTribeIndex();
- const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
- const parentRoot = rootOf(parentId);
- const all = await this.listAll();
- const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
- if (!userId) return subs;
- const out = [];
- for (const sub of subs) {
- const ok = await this.canAccessTribe(userId, sub.id).catch(() => false);
- if (ok) out.push(sub);
- }
- return out;
- },
- async isTribeMember(userId, tribeId) {
- if (!userId || !tribeId) return false;
- try {
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) return false;
- if (tribe.author === userId) return true;
- return Array.isArray(tribe.members) && tribe.members.includes(userId);
- } catch (e) {
- return false;
- }
- },
- async canAccessTribe(userId, tribeId) {
- if (!userId || !tribeId) return false;
- try {
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) return false;
- if (tribe.parentTribeId) {
- const parentOk = await this.canAccessTribe(userId, tribe.parentTribeId).catch(() => false);
- if (!parentOk) return false;
- }
- if (tribe.author === userId) return true;
- if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
- const effective = await this.getEffectiveStatus(tribeId);
- return !effective.isPrivate;
- } catch (e) {
- return false;
- }
- },
- async getEffectiveStatus(tribeId) {
- let current;
- try { current = await this.getTribeById(tribeId); } catch (e) { return { isPrivate: true, chain: [] }; }
- const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
- let cursor = current;
- const seen = new Set([current.id]);
- while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
- seen.add(cursor.parentTribeId);
- try {
- cursor = await this.getTribeById(cursor.parentTribeId);
- chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
- } catch (e) { break; }
- }
- const isPrivate = chain.some(c => c.isAnonymous);
- return { isPrivate, chain };
- },
- async listTribesForViewer(userId) {
- const all = await this.listAll();
- const out = [];
- for (const t of all) {
- if (!t.isAnonymous) { out.push(t); continue; }
- if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
- }
- return out;
- },
- async getViewerTribeScope(userId) {
- const all = await this.listAll();
- const memberOf = new Set();
- const createdBy = new Set();
- for (const t of all) {
- if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
- if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
- }
- return { memberOf, createdBy, allTribes: all };
- }
- };
- };
|