tribes_model.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. const pull = require('../server/node_modules/pull-stream');
  2. const crypto = require('crypto');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. const tribeLogLimit = Math.max(logLimit, 100000);
  6. const INVITE_CODE_BYTES = 16;
  7. const VALID_INVITE_MODES = ['strict', 'open'];
  8. module.exports = ({ cooler, tribeCrypto }) => {
  9. let ssb;
  10. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
  11. let tribeIndex = null;
  12. let tribeIndexTs = 0;
  13. const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isLARP', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
  14. const arraysEqual = (a, b) => {
  15. const aa = Array.isArray(a) ? a : [];
  16. const bb = Array.isArray(b) ? b : [];
  17. if (aa.length !== bb.length) return false;
  18. for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false;
  19. return true;
  20. };
  21. const validMembershipDelta = (prevMembers, nextMembers, author) => {
  22. const prev = Array.isArray(prevMembers) ? prevMembers : [];
  23. const next = Array.isArray(nextMembers) ? nextMembers : [];
  24. const added = next.filter(m => !prev.includes(m));
  25. const removed = prev.filter(m => !next.includes(m));
  26. if (added.length === 0 && removed.length === 0) return true;
  27. if (added.length === 1 && removed.length === 0 && added[0] === author) return true;
  28. if (removed.length === 1 && added.length === 0 && removed[0] === author) return true;
  29. return false;
  30. };
  31. const validInvitesDelta = (prevInvites, nextInvites, author, rootAuthor) => {
  32. if (author === rootAuthor) return true;
  33. const prevCodes = new Set((prevInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
  34. const nextCodes = new Set((nextInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
  35. for (const c of nextCodes) if (!prevCodes.has(c)) return false;
  36. return true;
  37. };
  38. const structuralFieldsEqual = (prev, next) => {
  39. for (const f of STRUCTURAL_FIELDS) {
  40. const a = prev[f];
  41. const b = next[f];
  42. if (Array.isArray(a) || Array.isArray(b)) { if (!arraysEqual(a, b)) return false; continue; }
  43. if (a !== b && !(a == null && b == null)) return false;
  44. }
  45. return true;
  46. };
  47. const buildTribeIndex = async () => {
  48. if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
  49. const client = await openSsb();
  50. return new Promise((resolve, reject) => {
  51. pull(
  52. client.createLogStream({ limit: tribeLogLimit }),
  53. pull.collect((err, msgs) => {
  54. if (err) return reject(err);
  55. const tombstones = new Map();
  56. const tribeMsgs = new Map();
  57. for (const msg of msgs) {
  58. const k = msg.key;
  59. const c = msg.value?.content;
  60. if (!c) continue;
  61. const author = msg.value?.author;
  62. if (c.type === 'tombstone' && c.target) {
  63. tombstones.set(c.target, { author, ts: msg.value?.timestamp });
  64. continue;
  65. }
  66. if (c.type !== 'tribe') continue;
  67. tribeMsgs.set(k, { id: k, content: c, author, _ts: msg.value?.timestamp });
  68. }
  69. const tribes = new Map();
  70. const parent = new Map();
  71. const child = new Map();
  72. const rootByTip = new Map();
  73. for (const [k, entry] of tribeMsgs.entries()) {
  74. const c = entry.content;
  75. if (!c.replaces) {
  76. tribes.set(k, entry);
  77. rootByTip.set(k, k);
  78. }
  79. }
  80. let progress = true;
  81. while (progress) {
  82. progress = false;
  83. const candidatesByReplaces = new Map();
  84. for (const [k, entry] of tribeMsgs.entries()) {
  85. if (tribes.has(k)) continue;
  86. const replaces = entry.content.replaces;
  87. if (!replaces) continue;
  88. const parentEntry = tribes.get(replaces);
  89. if (!parentEntry) continue;
  90. if (child.has(replaces)) continue;
  91. const root = rootByTip.get(replaces);
  92. const rootEntry = tribes.get(root);
  93. const rootAuthor = rootEntry?.author;
  94. const isRootAuthor = entry.author === rootAuthor;
  95. const prevMembers = Array.isArray(parentEntry.content.members) ? parentEntry.content.members : [];
  96. if (!isRootAuthor) {
  97. if (!prevMembers.includes(entry.author) && !(entry.content.members || []).includes(entry.author)) continue;
  98. if (!validMembershipDelta(prevMembers, entry.content.members, entry.author)) continue;
  99. if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
  100. if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
  101. }
  102. if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
  103. candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root });
  104. }
  105. for (const [replaces, candidates] of candidatesByReplaces.entries()) {
  106. if (child.has(replaces)) continue;
  107. let winner = candidates[0];
  108. for (let i = 1; i < candidates.length; i++) {
  109. const c = candidates[i];
  110. if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; }
  111. if (winner.isRootAuthor && !c.isRootAuthor) continue;
  112. const wt = winner.entry._ts || 0;
  113. const ct = c.entry._ts || 0;
  114. if (ct < wt) winner = c;
  115. else if (ct === wt && c.k < winner.k) winner = c;
  116. }
  117. parent.set(winner.k, replaces);
  118. child.set(replaces, winner.k);
  119. tribes.set(winner.k, winner.entry);
  120. rootByTip.set(winner.k, winner.root);
  121. progress = true;
  122. }
  123. }
  124. const tombstoned = new Set();
  125. for (const [target, t] of tombstones.entries()) {
  126. const tribeEntry = tribes.get(target);
  127. if (!tribeEntry) continue;
  128. const root = rootByTip.get(target);
  129. const rootAuthor = tribes.get(root)?.author;
  130. if (t.author === rootAuthor) tombstoned.add(target);
  131. }
  132. const rootOf = (id) => rootByTip.get(id) || id;
  133. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
  134. const tipByRoot = new Map();
  135. for (const k of tribes.keys()) {
  136. const root = rootOf(k);
  137. const tip = tipOf(root);
  138. tipByRoot.set(root, tip);
  139. }
  140. const effectivelyTombstoned = new Set(tombstoned);
  141. let cascadeProgress = true;
  142. while (cascadeProgress) {
  143. cascadeProgress = false;
  144. for (const k of tribes.keys()) {
  145. if (effectivelyTombstoned.has(k)) continue;
  146. const root = rootOf(k);
  147. if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascadeProgress = true; continue; }
  148. const entry = tribes.get(k);
  149. const pid = entry?.content?.parentTribeId;
  150. if (!pid) continue;
  151. const parentRoot = rootOf(pid);
  152. if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) {
  153. effectivelyTombstoned.add(k);
  154. cascadeProgress = true;
  155. }
  156. }
  157. }
  158. tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip };
  159. tribeIndexTs = Date.now();
  160. resolve(tribeIndex);
  161. })
  162. );
  163. });
  164. };
  165. return {
  166. type: 'tribe',
  167. async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
  168. if (!VALID_INVITE_MODES.includes(inviteMode)) {
  169. throw new Error('Invalid invite mode. Must be "strict" or "open"');
  170. }
  171. const ssb = await openSsb();
  172. const userId = ssb.id;
  173. let blobId = null;
  174. if (image) {
  175. blobId = String(image).trim() || null;
  176. }
  177. const tags = Array.isArray(tagsRaw)
  178. ? tagsRaw.filter(Boolean)
  179. : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
  180. const content = {
  181. type: 'tribe',
  182. title,
  183. description,
  184. image: blobId,
  185. location,
  186. tags,
  187. isLARP: Boolean(isLARP),
  188. isAnonymous: Boolean(isAnonymous),
  189. members: [userId],
  190. invites: [],
  191. inviteMode,
  192. status: status || 'OPEN',
  193. parentTribeId: parentTribeId || null,
  194. mapUrl: String(mapUrl || '').trim(),
  195. createdAt: new Date().toISOString(),
  196. updatedAt: new Date().toISOString(),
  197. author: userId,
  198. };
  199. const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
  200. if (tribeCrypto) {
  201. const tribeKey = tribeCrypto.generateTribeKey();
  202. tribeCrypto.setKey(result.key, tribeKey, 1);
  203. }
  204. tribeIndex = null;
  205. return result;
  206. },
  207. async generateInvite(tribeId) {
  208. const ssb = await openSsb();
  209. const userId = ssb.id;
  210. const tribe = await this.getTribeById(tribeId);
  211. if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
  212. throw new Error('Only the author can generate invites in strict mode');
  213. }
  214. if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
  215. throw new Error('Only tribe members can generate invites in open mode');
  216. }
  217. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
  218. let invite = code;
  219. if (tribeCrypto) {
  220. const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
  221. if (Array.isArray(ancestryIds) && ancestryIds.length) {
  222. const salt = tribeCrypto.generateInviteSalt();
  223. const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code, salt);
  224. if (ekChain) {
  225. invite = {
  226. codeHash: tribeCrypto.hashInviteCode(code, salt),
  227. ekChain,
  228. salt,
  229. gen: tribeCrypto.getGen(ancestryIds[0])
  230. };
  231. }
  232. }
  233. }
  234. const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
  235. await this.updateTribeInvites(tribeId, invites);
  236. return code;
  237. },
  238. async updateTribeInvites(tribeId, invites) {
  239. return this.updateTribeById(tribeId, { invites });
  240. },
  241. async leaveTribe(tribeId) {
  242. const ssb = await openSsb();
  243. const userId = ssb.id;
  244. const tribe = await this.getTribeById(tribeId);
  245. if (!tribe) throw new Error('Tribe not found');
  246. if (tribe.author === userId) {
  247. throw new Error('Tribe author cannot leave their own tribe');
  248. }
  249. const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
  250. const idx = members.indexOf(userId);
  251. if (idx === -1) throw new Error('User is not a member of this tribe');
  252. members.splice(idx, 1);
  253. await this.updateTribeById(tribeId, { members });
  254. await this.rotateTribeKey(tribeId, members);
  255. },
  256. async joinByInvite(code) {
  257. const ssb = await openSsb();
  258. const userId = ssb.id;
  259. const tribes = await this.listAll();
  260. let matchedTribe = null;
  261. let matchedInvite = null;
  262. for (const t of tribes) {
  263. if (!t.invites) continue;
  264. for (const inv of t.invites) {
  265. if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) {
  266. matchedTribe = t; matchedInvite = inv; break;
  267. }
  268. }
  269. if (matchedTribe) break;
  270. }
  271. if (!matchedTribe) throw new Error('Invalid or expired invite code');
  272. if (matchedTribe.members.includes(userId)) {
  273. throw new Error('Already a member of this tribe');
  274. }
  275. let storedTribeKey = null;
  276. let storedGen = 1;
  277. let storedRootId = null;
  278. if (tribeCrypto && typeof matchedInvite === 'object') {
  279. const salt = matchedInvite.salt;
  280. if (matchedInvite.ekChain) {
  281. const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt);
  282. if (Array.isArray(chain) && chain.length) {
  283. for (const entry of chain) {
  284. if (Array.isArray(entry.keys) && entry.keys.length) {
  285. tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
  286. } else if (entry.key) {
  287. tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
  288. }
  289. }
  290. storedRootId = chain[0].rootId;
  291. storedTribeKey = chain[0].key;
  292. storedGen = chain[0].gen || 1;
  293. }
  294. } else if (matchedInvite.ek) {
  295. storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt);
  296. storedRootId = await this.getRootId(matchedTribe.id);
  297. storedGen = matchedInvite.gen || 1;
  298. tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
  299. }
  300. }
  301. const members = [...matchedTribe.members, userId];
  302. const invites = matchedTribe.invites.filter(inv => {
  303. if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(inv, code);
  304. if (typeof inv === 'string') return inv !== code;
  305. return inv && inv.code !== code;
  306. });
  307. await this.updateTribeById(matchedTribe.id, { members, invites });
  308. if (tribeCrypto && storedTribeKey && storedRootId) {
  309. const ssbKeys = require('../server/node_modules/ssb-keys');
  310. const memberKeys = {};
  311. try { memberKeys[userId] = tribeCrypto.boxKeyForMember(storedTribeKey, userId, ssbKeys); } catch (_) {}
  312. if (matchedTribe.author && matchedTribe.author !== userId) {
  313. try { memberKeys[matchedTribe.author] = tribeCrypto.boxKeyForMember(storedTribeKey, matchedTribe.author, ssbKeys); } catch (_) {}
  314. }
  315. if (Object.keys(memberKeys).length) {
  316. await new Promise((resolve) => {
  317. ssb.publish({ type: 'tribe-keys', tribeId: storedRootId, generation: storedGen, memberKeys }, () => resolve());
  318. });
  319. }
  320. }
  321. await this.ensureFollowTribeMembers(matchedTribe.id).catch(() => {});
  322. return matchedTribe.id;
  323. },
  324. async deleteTribeById(tribeId) {
  325. await this.publishTombstone(tribeId);
  326. },
  327. async updateTribeMembers(tribeId, members) {
  328. const tribe = await this.getTribeById(tribeId);
  329. const oldMembers = tribe.members || [];
  330. await this.updateTribeById(tribeId, { members });
  331. const removed = oldMembers.filter(m => !members.includes(m));
  332. const added = members.filter(m => !oldMembers.includes(m));
  333. if (removed.length > 0) {
  334. await this.rotateTribeKey(tribeId, members);
  335. } else if (added.length > 0) {
  336. await this.distributeTribeKey(tribeId, added);
  337. }
  338. },
  339. async distributeTribeKey(tribeId, toMembers) {
  340. if (!tribeCrypto) return;
  341. const ssb = await openSsb();
  342. const ssbKeys = require('../server/node_modules/ssb-keys');
  343. const rootId = await this.getRootId(tribeId);
  344. const currentKey = tribeCrypto.getKey(rootId);
  345. if (!currentKey) return;
  346. const allKeys = tribeCrypto.getKeys(rootId);
  347. const gen = tribeCrypto.getGen(rootId);
  348. const payload = JSON.stringify({ keys: allKeys, gen });
  349. const memberKeys = {};
  350. const memberKeysFull = {};
  351. for (const memberId of toMembers) {
  352. try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
  353. try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(payload, memberId, ssbKeys); } catch (_) {}
  354. }
  355. if (!Object.keys(memberKeys).length && !Object.keys(memberKeysFull).length) return;
  356. await new Promise((resolve, reject) => {
  357. ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys, memberKeysFull }, (err, res) => err ? reject(err) : resolve(res));
  358. });
  359. await this.ensureFollowTribeMembers(tribeId).catch(() => {});
  360. },
  361. async ensureTribeKeyDistribution(tribeId) {
  362. if (!tribeCrypto) return;
  363. const ssb = await openSsb();
  364. const userId = ssb.id;
  365. const tribe = await this.getTribeById(tribeId).catch(() => null);
  366. if (!tribe || tribe.author !== userId) return;
  367. const rootId = await this.getRootId(tribeId);
  368. const currentKey = tribeCrypto.getKey(rootId);
  369. if (!currentKey) return;
  370. const gen = tribeCrypto.getGen(rootId);
  371. const msgs = await new Promise((resolve, reject) => {
  372. pull(ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
  373. });
  374. const distributed = new Set();
  375. for (const m of msgs) {
  376. const c = m.value?.content;
  377. if (!c || c.type !== 'tribe-keys') continue;
  378. if (c.tribeId !== rootId) continue;
  379. if ((c.generation || 0) < gen) continue;
  380. for (const mid of Object.keys(c.memberKeys || {})) distributed.add(mid);
  381. }
  382. const members = Array.isArray(tribe.members) ? tribe.members : [];
  383. const missing = members.filter(m => m !== userId && !distributed.has(m));
  384. if (missing.length > 0) await this.distributeTribeKey(tribeId, missing);
  385. },
  386. async publishUpdatedTribe(tribeId, updatedTribe) {
  387. const ssb = await openSsb();
  388. const updatedTribeData = {
  389. type: 'tribe',
  390. replaces: updatedTribe.replaces || tribeId,
  391. title: updatedTribe.title,
  392. description: updatedTribe.description,
  393. image: updatedTribe.image,
  394. location: updatedTribe.location,
  395. tags: updatedTribe.tags,
  396. isLARP: updatedTribe.isLARP,
  397. isAnonymous: updatedTribe.isAnonymous,
  398. members: updatedTribe.members,
  399. invites: updatedTribe.invites,
  400. inviteMode: updatedTribe.inviteMode,
  401. status: updatedTribe.status || 'OPEN',
  402. parentTribeId: updatedTribe.parentTribeId || null,
  403. mapUrl: updatedTribe.mapUrl || "",
  404. createdAt: updatedTribe.createdAt,
  405. updatedAt: new Date().toISOString(),
  406. author: updatedTribe.author,
  407. };
  408. const result = await new Promise((resolve, reject) => {
  409. ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
  410. });
  411. tribeIndex = null;
  412. return result;
  413. },
  414. async getTribeById(tribeId) {
  415. const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex();
  416. let latestId = tribeId;
  417. while (child.has(latestId)) latestId = child.get(latestId);
  418. if (tombstoned.has(latestId) || effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found');
  419. const tribe = tribes.get(latestId);
  420. if (!tribe) throw new Error('Tribe not found');
  421. return {
  422. id: tribe.id,
  423. title: tribe.content.title,
  424. description: tribe.content.description,
  425. image: tribe.content.image || null,
  426. location: tribe.content.location,
  427. tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
  428. isLARP: !!tribe.content.isLARP,
  429. isAnonymous: tribe.content.isAnonymous,
  430. members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
  431. invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
  432. inviteMode: tribe.content.inviteMode || 'strict',
  433. status: tribe.content.status || 'OPEN',
  434. parentTribeId: tribe.content.parentTribeId || null,
  435. mapUrl: tribe.content.mapUrl || "",
  436. createdAt: tribe.content.createdAt,
  437. updatedAt: tribe.content.updatedAt,
  438. author: tribe.content.author,
  439. };
  440. },
  441. async listAll() {
  442. const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
  443. const resolveParent = (pid) => {
  444. if (!pid) return null;
  445. const root = rootByTip.get(pid) || pid;
  446. return tipByRoot.get(root) || pid;
  447. };
  448. const items = [];
  449. for (const [root, tip] of tipByRoot) {
  450. if (tombstoned.has(root) || tombstoned.has(tip)) continue;
  451. if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue;
  452. const entry = tribes.get(tip);
  453. if (!entry) continue;
  454. const c = entry.content;
  455. items.push({
  456. id: tip,
  457. title: c.title,
  458. description: c.description,
  459. image: c.image || null,
  460. location: c.location,
  461. tags: Array.isArray(c.tags) ? c.tags : [],
  462. isLARP: !!c.isLARP,
  463. isAnonymous: c.isAnonymous !== false,
  464. members: Array.isArray(c.members) ? c.members : [],
  465. invites: Array.isArray(c.invites) ? c.invites : [],
  466. inviteMode: c.inviteMode || 'strict',
  467. status: c.status || 'OPEN',
  468. parentTribeId: resolveParent(c.parentTribeId),
  469. mapUrl: c.mapUrl || "",
  470. createdAt: c.createdAt,
  471. updatedAt: c.updatedAt,
  472. author: c.author,
  473. _ts: entry._ts
  474. });
  475. }
  476. return items;
  477. },
  478. async getChainIds(tribeId) {
  479. const { parent, child } = await buildTribeIndex();
  480. let root = tribeId;
  481. while (parent.has(root)) root = parent.get(root);
  482. const ids = [root];
  483. let cur = root;
  484. while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
  485. return ids;
  486. },
  487. async getRootId(tribeId) {
  488. const { parent } = await buildTribeIndex();
  489. let root = tribeId;
  490. while (parent.has(root)) root = parent.get(root);
  491. return root;
  492. },
  493. async getAncestryChain(tribeId) {
  494. const rootId = await this.getRootId(tribeId);
  495. const tribe = await this.getTribeById(tribeId);
  496. const chain = [rootId];
  497. let currentTribe = tribe;
  498. while (currentTribe.parentTribeId) {
  499. const parentRootId = await this.getRootId(currentTribe.parentTribeId);
  500. chain.push(parentRootId);
  501. try {
  502. currentTribe = await this.getTribeById(currentTribe.parentTribeId);
  503. } catch (e) {
  504. break;
  505. }
  506. }
  507. return chain;
  508. },
  509. async rotateTribeKey(tribeId, remainingMembers) {
  510. if (!tribeCrypto) return;
  511. const ssb = await openSsb();
  512. const ssbKeys = require('../server/node_modules/ssb-keys');
  513. const rootId = await this.getRootId(tribeId);
  514. const oldKey = tribeCrypto.getKey(rootId);
  515. if (!oldKey) return;
  516. const newKey = tribeCrypto.generateTribeKey();
  517. const newGen = tribeCrypto.addNewKey(rootId, newKey);
  518. const allKeys = tribeCrypto.getKeys(rootId);
  519. const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen });
  520. const memberKeys = {};
  521. const memberKeysFull = {};
  522. for (const memberId of remainingMembers) {
  523. try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); } catch (_) {}
  524. try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(fullPayload, memberId, ssbKeys); } catch (_) {}
  525. }
  526. const entries = Object.entries(memberKeys);
  527. const BATCH_SIZE = 20;
  528. for (let i = 0; i < entries.length; i += BATCH_SIZE) {
  529. const batchSingle = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
  530. const batchFull = {};
  531. for (const id of Object.keys(batchSingle)) {
  532. if (memberKeysFull[id]) batchFull[id] = memberKeysFull[id];
  533. }
  534. await new Promise((resolve, reject) => {
  535. ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batchSingle, memberKeysFull: batchFull },
  536. (err, res) => err ? reject(err) : resolve(res));
  537. });
  538. }
  539. const tribe = await this.getTribeById(tribeId);
  540. if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
  541. const survivingInvites = tribe.invites.map(inv => {
  542. if (typeof inv === 'string') return inv;
  543. if (!inv || typeof inv !== 'object') return inv;
  544. const next = { ...inv, gen: newGen };
  545. delete next.ekChain;
  546. delete next.ek;
  547. return next;
  548. });
  549. await this.updateTribeInvites(tribeId, survivingInvites);
  550. }
  551. },
  552. async processIncomingKeys() {
  553. if (!tribeCrypto) return;
  554. const ssb = await openSsb();
  555. const ssbKeys = require('../server/node_modules/ssb-keys');
  556. const config = require('../server/ssb_config');
  557. const msgs = await new Promise((resolve, reject) => {
  558. pull(
  559. ssb.createLogStream({ limit: tribeLogLimit }),
  560. pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
  561. );
  562. });
  563. const byTribe = new Map();
  564. for (const m of msgs) {
  565. const c = m.value?.content;
  566. if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue;
  567. const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id];
  568. const singleEntry = c.memberKeys && c.memberKeys[ssb.id];
  569. if (!fullEntry && !singleEntry) continue;
  570. const list = byTribe.get(c.tribeId) || [];
  571. list.push({ generation: c.generation || 0, fullEntry, singleEntry });
  572. byTribe.set(c.tribeId, list);
  573. }
  574. for (const [tribeId, entries] of byTribe.entries()) {
  575. entries.sort((a, b) => b.generation - a.generation);
  576. const top = entries[0];
  577. const knownGen = tribeCrypto.getGen(tribeId);
  578. if (top.fullEntry) {
  579. try {
  580. const text = tribeCrypto.unboxKeyFromMember(top.fullEntry, config.keys, ssbKeys);
  581. const parsed = text ? JSON.parse(text) : null;
  582. if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) {
  583. tribeCrypto.mergeKeys(tribeId, parsed.keys, parsed.gen || top.generation || knownGen);
  584. continue;
  585. }
  586. } catch (_) {}
  587. }
  588. if (top.singleEntry && top.generation > knownGen) {
  589. const newKey = tribeCrypto.unboxKeyFromMember(top.singleEntry, config.keys, ssbKeys);
  590. if (newKey) tribeCrypto.addNewKey(tribeId, newKey);
  591. }
  592. }
  593. },
  594. async ensureFollowTribeMembers(tribeId) {
  595. const ssb = await openSsb();
  596. const me = ssb.id;
  597. let tribe;
  598. try { tribe = await this.getTribeById(tribeId); } catch { return; }
  599. const rootId = await this.getRootId(tribeId).catch(() => tribeId);
  600. const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
  601. const tribeRootSet = new Set([rootId]);
  602. const tribeChainSet = new Set(tribeChainIds);
  603. tribeChainSet.add(tribeId);
  604. const discovered = new Set();
  605. const myFollows = new Map();
  606. await new Promise((resolve, reject) => {
  607. pull(
  608. ssb.createLogStream({ limit: tribeLogLimit }),
  609. pull.collect((err, msgs) => {
  610. if (err) return reject(err);
  611. for (const m of msgs) {
  612. const v = m.value;
  613. if (!v) continue;
  614. const c = v.content;
  615. if (!c) continue;
  616. if (v.author === me && c.type === 'contact' && c.contact && typeof c.following === 'boolean') {
  617. myFollows.set(c.contact, c.following);
  618. continue;
  619. }
  620. if (c.type === 'tribe-keys' && c.tribeId && tribeRootSet.has(c.tribeId) && c.memberKeys && typeof c.memberKeys === 'object') {
  621. for (const fid of Object.keys(c.memberKeys)) discovered.add(fid);
  622. if (v.author) discovered.add(v.author);
  623. continue;
  624. }
  625. if (c.type === 'tribe' && Array.isArray(c.members)) {
  626. if (tribeChainSet.has(m.key) || tribeChainSet.has(c.replaces || '')) {
  627. for (const fid of c.members) if (fid) discovered.add(fid);
  628. if (c.author) discovered.add(c.author);
  629. }
  630. }
  631. }
  632. resolve();
  633. })
  634. );
  635. });
  636. const baseMembers = Array.isArray(tribe.members) ? tribe.members : [];
  637. for (const fid of baseMembers) discovered.add(fid);
  638. if (tribe.author) discovered.add(tribe.author);
  639. discovered.delete(me);
  640. const members = [...discovered].filter(Boolean);
  641. if (!members.length) return;
  642. for (const memberId of members) {
  643. if (myFollows.get(memberId) === true) continue;
  644. await new Promise((resolve) => {
  645. ssb.publish({ type: 'contact', contact: memberId, following: true }, () => resolve());
  646. });
  647. }
  648. },
  649. async updateTribeById(tribeId, updatedContent) {
  650. const ssb = await openSsb();
  651. const tribe = await this.getTribeById(tribeId);
  652. if (!tribe) throw new Error('Tribe not found');
  653. const updatedTribe = {
  654. type: 'tribe',
  655. ...tribe,
  656. ...updatedContent,
  657. replaces: tribeId,
  658. updatedAt: new Date().toISOString()
  659. };
  660. return this.publishUpdatedTribe(tribeId, updatedTribe);
  661. },
  662. async publishTombstone(tribeId) {
  663. const ssb = await openSsb();
  664. const userId = ssb.id;
  665. const tombstone = {
  666. type: 'tombstone',
  667. target: tribeId,
  668. deletedAt: new Date().toISOString(),
  669. author: userId
  670. };
  671. await new Promise((resolve, reject) => {
  672. ssb.publish(tombstone, (err) => {
  673. if (err) return reject(err);
  674. resolve();
  675. });
  676. });
  677. tribeIndex = null;
  678. },
  679. async listSubTribes(parentId, userId) {
  680. const idx = await buildTribeIndex();
  681. const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
  682. const parentRoot = rootOf(parentId);
  683. const all = await this.listAll();
  684. const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
  685. if (!userId) return subs;
  686. const out = [];
  687. for (const sub of subs) {
  688. const ok = await this.canAccessTribe(userId, sub.id).catch(() => false);
  689. if (ok) out.push(sub);
  690. }
  691. return out;
  692. },
  693. async isTribeMember(userId, tribeId) {
  694. if (!userId || !tribeId) return false;
  695. try {
  696. const tribe = await this.getTribeById(tribeId);
  697. if (!tribe) return false;
  698. if (tribe.author === userId) return true;
  699. return Array.isArray(tribe.members) && tribe.members.includes(userId);
  700. } catch (e) {
  701. return false;
  702. }
  703. },
  704. async canAccessTribe(userId, tribeId) {
  705. if (!userId || !tribeId) return false;
  706. try {
  707. const tribe = await this.getTribeById(tribeId);
  708. if (!tribe) return false;
  709. if (tribe.parentTribeId) {
  710. const parentOk = await this.canAccessTribe(userId, tribe.parentTribeId).catch(() => false);
  711. if (!parentOk) return false;
  712. }
  713. if (tribe.author === userId) return true;
  714. if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
  715. const effective = await this.getEffectiveStatus(tribeId);
  716. return !effective.isPrivate;
  717. } catch (e) {
  718. return false;
  719. }
  720. },
  721. async getEffectiveStatus(tribeId) {
  722. let current;
  723. try { current = await this.getTribeById(tribeId); } catch (e) { return { isPrivate: true, chain: [] }; }
  724. const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
  725. let cursor = current;
  726. const seen = new Set([current.id]);
  727. while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
  728. seen.add(cursor.parentTribeId);
  729. try {
  730. cursor = await this.getTribeById(cursor.parentTribeId);
  731. chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
  732. } catch (e) { break; }
  733. }
  734. const isPrivate = chain.some(c => c.isAnonymous);
  735. return { isPrivate, chain };
  736. },
  737. async listTribesForViewer(userId) {
  738. const all = await this.listAll();
  739. const out = [];
  740. for (const t of all) {
  741. if (!t.isAnonymous) { out.push(t); continue; }
  742. if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
  743. }
  744. return out;
  745. },
  746. async getViewerTribeScope(userId) {
  747. const all = await this.listAll();
  748. const memberOf = new Set();
  749. const createdBy = new Set();
  750. for (const t of all) {
  751. if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
  752. if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
  753. }
  754. return { memberOf, createdBy, allTribes: all };
  755. }
  756. };
  757. };