ソースを参照

Oasis release 0.7.5

psy 9 時間 前
コミット
31ad98e62d
36 ファイル変更1699 行追加497 行削除
  1. 15 0
      docs/CHANGELOG.md
  2. 263 53
      src/backend/backend.js
  3. 42 48
      src/client/assets/styles/style.css
  4. 0 3
      src/client/assets/themes/Clear-SNH.css
  5. 0 3
      src/client/assets/themes/Dark-SNH.css
  6. 0 3
      src/client/assets/themes/Matrix-SNH.css
  7. 0 3
      src/client/assets/themes/Purple-SNH.css
  8. 24 0
      src/client/assets/translations/oasis_ar.js
  9. 24 0
      src/client/assets/translations/oasis_de.js
  10. 23 0
      src/client/assets/translations/oasis_en.js
  11. 24 0
      src/client/assets/translations/oasis_es.js
  12. 24 0
      src/client/assets/translations/oasis_eu.js
  13. 24 0
      src/client/assets/translations/oasis_fr.js
  14. 24 0
      src/client/assets/translations/oasis_hi.js
  15. 24 0
      src/client/assets/translations/oasis_it.js
  16. 24 0
      src/client/assets/translations/oasis_pt.js
  17. 24 0
      src/client/assets/translations/oasis_ru.js
  18. 24 0
      src/client/assets/translations/oasis_zh.js
  19. 215 119
      src/models/calendars_model.js
  20. 10 1
      src/models/chats_model.js
  21. 104 39
      src/models/maps_model.js
  22. 9 1
      src/models/pads_model.js
  23. 100 6
      src/models/parliament_model.js
  24. 85 35
      src/models/torrents_model.js
  25. 109 8
      src/models/tribe_crypto.js
  26. 26 25
      src/models/tribes_content_model.js
  27. 198 22
      src/models/tribes_model.js
  28. 1 1
      src/server/package-lock.json
  29. 1 1
      src/server/package.json
  30. 0 1
      src/server/ssb_config.js
  31. 8 2
      src/views/blockchain_view.js
  32. 2 2
      src/views/chats_view.js
  33. 3 3
      src/views/pads_view.js
  34. 27 27
      src/views/stats_view.js
  35. 2 0
      src/views/torrents_view.js
  36. 216 91
      src/views/tribes_view.js

+ 15 - 0
docs/CHANGELOG.md

@@ -13,6 +13,21 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.5 - 2026-05-01
+
+### Added
+                                                                                                                                              
+- Lists tags with counts and filters items (Tribes plugin).
+- Sub-tribe invite carries full ancestry key chain (Tribes plugin).
+                                                                                                                                                                                                   
+### Changed
+
+- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
+
+### Fixed
+
+- Governance cycles and general parliament proposals (Tribes plugin).
+
 ## v0.7.4 - 2026-04-25
 
 ### Added

+ 263 - 53
src/backend/backend.js

@@ -61,7 +61,7 @@ const ensureTerm = async () => {
 let sweepInFlight = null;
 const runSweepOnce = async () => {
   if (sweepInFlight) return sweepInFlight;
-  sweepInFlight = parliamentModel.sweepProposals().catch(() => {}).finally(() => { sweepInFlight = null; });
+  sweepInFlight = parliamentModel.sweepProposals().catch(e => console.error('sweepProposals failed:', e)).finally(() => { sweepInFlight = null; });
   return sweepInFlight;
 };
 
@@ -260,21 +260,21 @@ const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.p
 const votesModel = require('../models/votes_model')({ cooler, isPublic: config.public });
 const ssbConfig = require('../server/ssb_config');
 const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path);
+const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
 const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
-const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
+const calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, tribesModel });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
 const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
 const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public });
 const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public });
-const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public });
+const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
 const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public });
 const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public });
 const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
-const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
 const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, tribesModel });
 const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
@@ -288,7 +288,7 @@ const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.pub
 const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
-const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
+const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel });
@@ -1163,7 +1163,7 @@ router
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     const myTribeIds = await getUserTribeIds(uid);
-    enriched = enriched.filter(x => !x.tribeId || myTribeIds.has(x.tribeId));
+    enriched = enriched.filter(x => !x.tribeId);
     enriched = await applyListFilters(enriched, ctx);
     try {
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
@@ -1174,7 +1174,10 @@ router
   })
   .get("/maps/edit/:id", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
-    const mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId());
+    let mapItem;
+    try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; }
+    if (!mapItem) { ctx.redirect('/maps?filter=all'); return; }
+    if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; }
     const fav = await mediaFavorites.getFavoriteSet('maps');
     ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' });
   })
@@ -1182,7 +1185,14 @@ router
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
     const uid = getViewerId();
-    const mapItem = await mapsModel.getMapById(mapId, uid);
+    let mapItem;
+    try {
+      mapItem = await mapsModel.getMapById(mapId, uid);
+    } catch (e) {
+      ctx.redirect('/maps?filter=all');
+      return;
+    }
+    if (!mapItem) { ctx.redirect('/maps?filter=all'); return; }
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let tribeMembers = [];
     let parentTribe = null;
@@ -1225,13 +1235,13 @@ router
   })
   .get("/torrents", async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
-    const { filter = 'all', q = '', sort = 'recent' } = ctx.query;
+    const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query;
     const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
     const fav = await mediaFavorites.getFavoriteSet('torrents');
-    let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
+    let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     enriched = await applyListFilters(enriched, ctx);
-    ctx.body = await torrentsView(enriched, filter, null, { q, sort });
+    ctx.body = await torrentsView(enriched, filter, null, { q, sort, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/torrents/edit/:id", async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
@@ -1575,6 +1585,7 @@ router
     if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
     await tribesModel.processIncomingKeys().catch(() => {});
     await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {});
+    await tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {});
     const listByTribeAllChain = async (tribeId, contentType) => {
       const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
       const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => [])));
@@ -1615,18 +1626,22 @@ router
         const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
         subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title })));
       }
-      const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw] = await Promise.all([
+      const [allPadsRaw, allChatsRaw, allCalsRaw, allMapsRaw, allTorrentsRaw, tribeChain] = await Promise.all([
         padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
-        mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => [])
+        mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
+        torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
       ]);
+      const tribeChainSet = new Set(tribeChain);
       const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) });
       const standaloneItems = [
-        ...allPadsRaw.filter(p => p.tribeId === tribe.id).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)),
-        ...allChatsRaw.filter(c => c.tribeId === tribe.id).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)),
-        ...allCalsRaw.filter(c => c.tribeId === tribe.id).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)),
-        ...allMapsRaw.filter(m => m.tribeId === tribe.id).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`))
+        ...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)),
+        ...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)),
+        ...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)),
+        ...allMapsRaw.filter(m => tribeChainSet.has(m.tribeId)).map(toStandalone('map', m => `/maps/${encodeURIComponent(m.key || m.id)}`)),
+        ...allTorrentsRaw.filter(t => tribeChainSet.has(t.tribeId)).map(toStandalone('torrent', t => `/torrents/${encodeURIComponent(t.rootId || t.key)}`))
       ];
       const combined = [...allContent, ...subContent, ...standaloneItems];
       const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
@@ -1647,10 +1662,29 @@ router
       sectionData = { items, period };
     } else if (section === 'tags') {
       const allContent = await listByTribeAllChain(tribe.id, null);
+      const [allPadsT, allChatsT, allCalsT, allMapsT, allTorrentsT, subTribesT, tribeChainT] = await Promise.all([
+        padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
+        torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.listSubTribes(tribe.id).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSetT = new Set(tribeChainT);
+      const standaloneTagged = [
+        ...allPadsT.filter(p => tribeChainSetT.has(p.tribeId)).map(p => ({ ...p, contentType: 'pad', id: p.rootId || p.key })),
+        ...allChatsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'chat', id: c.rootId || c.key })),
+        ...allCalsT.filter(c => tribeChainSetT.has(c.tribeId)).map(c => ({ ...c, contentType: 'calendar', id: c.rootId || c.key })),
+        ...allMapsT.filter(m => tribeChainSetT.has(m.tribeId)).map(m => ({ ...m, contentType: 'map', id: m.rootId || m.key })),
+        ...allTorrentsT.filter(t => tribeChainSetT.has(t.tribeId)).map(t => ({ ...t, contentType: 'torrent', id: t.rootId || t.key })),
+        ...subTribesT.map(st => ({ ...st, contentType: 'tribe', tags: Array.isArray(st.tags) ? st.tags : [], title: st.title, description: st.description, author: st.author, createdAt: st.createdAt }))
+      ];
+      const allTaggable = [...allContent, ...standaloneTagged];
       const tagMap = new Map();
-      for (const item of allContent) {
+      for (const item of allTaggable) {
         for (const tag of (item.tags || []).filter(Boolean)) {
-          const lower = tag.toLowerCase().trim();
+          const lower = String(tag).toLowerCase().trim();
           if (!lower) continue;
           if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] });
           const entry = tagMap.get(lower);
@@ -1661,17 +1695,56 @@ router
       const selectedTag = (ctx.query.tag || '').toLowerCase().trim();
       sectionData = { tags: [...tagMap.values()].sort((a, b) => b.count - a.count), selectedTag, filteredItems: selectedTag && tagMap.has(selectedTag) ? tagMap.get(selectedTag).items : [] };
     } else if (section === 'maps') {
-      const allMaps = await mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []);
-      sectionData = allMaps.filter(m => m.tribeId === tribe.id);
+      const [allMaps, tribeChain] = await Promise.all([
+        mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSet = new Set(tribeChain);
+      sectionData = allMaps.filter(m => tribeChainSet.has(m.tribeId));
     } else if (section === 'pads') {
-      const allPads = await padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
-      sectionData = allPads.filter(p => p.tribeId === tribe.id);
+      const [allPads, tribeChain] = await Promise.all([
+        padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSet = new Set(tribeChain);
+      sectionData = allPads.filter(p => tribeChainSet.has(p.tribeId));
     } else if (section === 'chats') {
-      const allChats = await chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
-      sectionData = allChats.filter(c => c.tribeId === tribe.id);
+      const [allChats, tribeChain] = await Promise.all([
+        chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSet = new Set(tribeChain);
+      sectionData = allChats.filter(c => tribeChainSet.has(c.tribeId));
     } else if (section === 'calendars') {
-      const allCals = await calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []);
-      sectionData = allCals.filter(c => c.tribeId === tribe.id);
+      const [allCals, tribeChain] = await Promise.all([
+        calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSet = new Set(tribeChain);
+      sectionData = allCals.filter(c => tribeChainSet.has(c.tribeId));
+    } else if (section === 'torrents') {
+      const [allTorrents, tribeChain] = await Promise.all([
+        torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
+        tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
+      ]);
+      const tribeChainSet = new Set(tribeChain);
+      const standaloneTorrents = allTorrents.filter(t => tribeChainSet.has(t.tribeId));
+      const mediaTorrents = (await listByTribeAllChain(tribe.id, 'media').catch(() => []))
+        .filter(m => m.mediaType === 'torrent')
+        .map(m => ({
+          key: m.id,
+          rootId: m.id,
+          title: m.title || '',
+          description: m.description || '',
+          url: m.image || '',
+          tags: Array.isArray(m.tags) ? m.tags : [],
+          author: m.author,
+          createdAt: m.createdAt,
+          updatedAt: m.updatedAt,
+          tribeId: m.tribeId,
+          _isMedia: true
+        }));
+      sectionData = [...standaloneTorrents, ...mediaTorrents].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
     } else if (section === 'search') {
       const sq = (ctx.query.q || '').trim().toLowerCase();
       let results = [];
@@ -1698,9 +1771,11 @@ router
       const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
       sectionData = { events, tasks, feed };
     } else if (section === 'governance') {
+      if (tribe.parentTribeId) { ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}?section=activity`); return; }
       const gf = String(ctx.query.filter || 'government');
       const isCreator = tribe.author === uid;
       const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
+      if (isCreator) { try { await parliamentModel.tribe.ensureTerm(tribe.id); } catch (_) {} }
       const [term, candidatures, rules, globalTermBase] = await Promise.all([
         parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null),
         parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []),
@@ -2187,7 +2262,7 @@ router
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const myTribeIds = await getUserTribeIds(viewerId);
-    const enriched = items.filter(x => !x.tribeId || myTribeIds.has(x.tribeId)).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
+    const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
     finalList = await applyListFilters(finalList, ctx);
     ctx.body = await chatsView(finalList, filter, null, { q });
@@ -2213,13 +2288,10 @@ router
         chat = await chatsModel.getChatById(ctx.params.chatId);
       } catch { ctx.redirect('/tribes'); return; }
     }
-    if (String(chat.status || '').toUpperCase() === 'INVITE-ONLY' && chat.author !== uid) {
-      const invited = Array.isArray(chat.invites) && chat.invites.includes(uid);
-      if (!invited) { ctx.body = inviteRequiredView('chat', parentTribe); return; }
-    }
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
-    ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)) }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
+    const isTribeMember = !!parentTribe;
+    ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']) });
   })
   .get("/pads", async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
@@ -2255,10 +2327,6 @@ router
         pad = await padsModel.getPadById(ctx.params.padId);
       } catch { ctx.redirect('/tribes'); return; }
     }
-    if (String(pad.status || '').toUpperCase() === 'INVITE-ONLY' && pad.author !== uid) {
-      const invited = Array.isArray(pad.invites) && pad.invites.includes(uid);
-      if (!invited) { ctx.body = inviteRequiredView('pad', parentTribe); return; }
-    }
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
     const versionKey = ctx.query.version || null;
@@ -2266,7 +2334,8 @@ router
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       : null;
     const baseUrl = `${ctx.protocol}://${ctx.host}`;
-    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)) }, entries, { baseUrl, selectedVersion });
+    const isTribeMember = !!parentTribe;
+    ctx.body = await singlePadView({ ...pad, isFavorite: fav.has(String(pad.rootId)), isTribeMember }, entries, { baseUrl, selectedVersion });
   })
   .get("/calendars", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
@@ -2286,7 +2355,7 @@ router
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('calendars');
     const myTribeIds = await getUserTribeIds(uid);
-    const enriched = calendars.filter(c => !c.tribeId || myTribeIds.has(c.tribeId)).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
+    const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) }));
     let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
     finalList = await applyListFilters(finalList, ctx);
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
@@ -3047,6 +3116,10 @@ router
   .post("/maps/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
+    if (b.tribeId) {
+      const t = await tribesModel.getTribeById(b.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const imageId = extractBlobId(await handleBlobUpload(ctx, 'image')) || "";
     const newMap = await mapsModel.createMap(b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), b.tribeId || null, stripDangerousTags(b.markerLabel), imageId);
     const redir = b.tribeId ? `/tribe/${encodeURIComponent(b.tribeId)}?section=maps` : safeReturnTo(ctx, '/maps?filter=all', ['/maps']);
@@ -3054,6 +3127,11 @@ router
   })
   .post("/maps/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
+    const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const b = ctx.request.body;
     const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : "";
     await mapsModel.updateMapById(ctx.params.id, b.lat, b.lng, stripDangerousTags(b.description), b.mapType, b.tags, stripDangerousTags(b.title), imageId || undefined);
@@ -3061,6 +3139,11 @@ router
   })
   .post("/maps/delete/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
+    const target = await mapsModel.getMapById(ctx.params.id, getViewerId()).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     await mapsModel.deleteMapById(ctx.params.id);
     ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps']));
   })
@@ -3090,21 +3173,45 @@ router
   .post("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId'))
   .post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
+    const { tags, title, description, tribeId } = ctx.request.body;
+    const cleanTribeId = tribeId ? String(tribeId).trim() : null;
+    if (cleanTribeId) {
+      const t = await tribesModel.getTribeById(cleanTribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const blob = await handleBlobUpload(ctx, 'torrent');
     const fileSize = ctx.request.files?.torrent?.size || 0;
-    const { tags, title, description } = ctx.request.body;
-    await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize);
-    ctx.redirect(safeReturnTo(ctx, '/torrents?filter=all', ['/torrents']));
+    await torrentsModel.createTorrent(blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description), fileSize, cleanTribeId);
+    ctx.redirect(cleanTribeId ? `/tribe/${encodeURIComponent(cleanTribeId)}?section=torrents` : safeReturnTo(ctx, '/torrents?filter=all', ['/torrents']));
   })
   .post("/torrents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
+    const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const { tags, title, description } = ctx.request.body;
     const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null;
     await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
     ctx.redirect(safeReturnTo(ctx, '/torrents?filter=mine', ['/torrents']));
   })
-  .post("/torrents/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'torrents'))
-  .post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => opinionAction(ctx, 'torrents', 'torrentId'))
+  .post("/torrents/delete/:id", koaBody(), async ctx => {
+    const target = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
+    return deleteAction(ctx, 'torrents');
+  })
+  .post("/torrents/opinions/:torrentId/:category", koaBody(), async ctx => {
+    const target = await torrentsModel.getTorrentById(ctx.params.torrentId, getViewerId()).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
+    return opinionAction(ctx, 'torrents', 'torrentId');
+  })
   .post("/torrents/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'add'))
   .post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove'))
   .post("/torrents/:torrentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'torrents', 'torrentId'))
@@ -3161,7 +3268,8 @@ router
     if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
     if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
     const image = await handleBlobUpload(ctx, 'image');
-    await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl));
+    const tribeRes = await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', b.isAnonymous === 'true', b.inviteMode, null, 'OPEN', stripDangerousTags(b.mapUrl));
+    try { if (tribeRes?.key) await parliamentModel.tribe.publishInitialTerm(tribeRes.key); } catch (e) { console.error('publishInitialTerm failed:', e); }
     ctx.redirect('/tribes');
   })
   .post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
@@ -3176,9 +3284,8 @@ router
     if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
     const image = await handleBlobUpload(ctx, 'image');
     const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false }));
-    const requestedAnonymous = b.isAnonymous === 'true';
-    const effectiveAnonymous = parentEffective.isPrivate ? true : requestedAnonymous;
-    await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, b.isLARP === 'true', effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl));
+    const effectiveAnonymous = !!(parentEffective.isPrivate || parentTribe.isAnonymous);
+    await tribesModel.createTribe(stripDangerousTags(b.title), stripDangerousTags(b.description), image, stripDangerousTags(b.location), b.tags, false, effectiveAnonymous, b.inviteMode || 'open', ctx.params.id, 'OPEN', stripDangerousTags(b.mapUrl));
     ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`);
   })
   .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
@@ -3189,7 +3296,16 @@ router
     if (tooLong(ctx, b.title, MAX_TITLE_LENGTH, 'Title') || tooLong(ctx, b.description, MAX_TEXT_LENGTH, 'Description')) return;
     if (b.inviteMode && !['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
     const tags = b.tags ? b.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
-    await tribesModel.updateTribeById(ctx.params.id, { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, isLARP: b.isLARP === 'true', isAnonymous: b.isAnonymous === 'true', inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' });
+    const isSub = !!tribe.parentTribeId;
+    const updateFields = { title: stripDangerousTags(b.title), description: stripDangerousTags(b.description), image: await handleBlobUpload(ctx, 'image'), location: stripDangerousTags(b.location), tags, inviteMode: b.inviteMode || tribe.inviteMode, status: b.status || tribe.status || 'OPEN' };
+    if (isSub) {
+      updateFields.isLARP = false;
+      updateFields.isAnonymous = !!tribe.isAnonymous;
+    } else {
+      updateFields.isLARP = b.isLARP === 'true';
+      updateFields.isAnonymous = b.isAnonymous === 'true';
+    }
+    await tribesModel.updateTribeById(ctx.params.id, updateFields);
     ctx.redirect('/tribes?filter=mine');
   })
   .post('/tribes/delete/:id', async ctx => {
@@ -3513,6 +3629,7 @@ router
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
     const isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
@@ -3520,7 +3637,8 @@ router
     const already = await parliamentModel.tribe.hasCandidatureInGlobalCycle(tribeId, globalTerm?.startAt).catch(() => false);
     if (already) ctx.throw(400, 'This tribe already has an open candidature in the current global parliament cycle.');
     const term = await parliamentModel.tribe.getCurrentTerm(tribeId).catch(() => null);
-    const method = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY';
+    const rawMethod = (term?.method && String(term.method).toUpperCase()) || 'DEMOCRACY';
+    const method = rawMethod === 'ANARCHY' ? 'DEMOCRACY' : rawMethod;
     await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=candidatures');
   })
@@ -3529,6 +3647,7 @@ router
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
     const isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
@@ -3544,6 +3663,7 @@ router
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
     const isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
@@ -3557,6 +3677,7 @@ router
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
     if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules');
     const b = ctx.request.body || {};
     await parliamentModel.tribe.publishTribeRule({ tribeId, title: stripDangerousTags(String(b.title || '')), body: stripDangerousTags(String(b.body || '')) }).catch(e => ctx.throw(400, String(e?.message || e)));
@@ -3567,6 +3688,7 @@ router
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
+    if (tribe.parentTribeId) ctx.throw(400, 'Sub-tribes have no governance');
     if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules');
     const ruleId = String(ctx.request.body?.ruleId || '').trim();
     if (!ruleId) ctx.throw(400, 'Missing ruleId');
@@ -3881,8 +4003,12 @@ router
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
     const tribeId = b.tribeId || null;
+    if (tribeId) {
+      const t = await tribesModel.getTribeById(tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+      await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
+    }
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
-    if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
     await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId);
     ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
   })
@@ -3923,6 +4049,19 @@ router
   })
   .post("/chats/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
+    const uid = getViewerId();
+    const chat = await chatsModel.getChatById(ctx.params.id);
+    if (!chat) { ctx.status = 404; ctx.body = "Chat not found"; return; }
+    if (chat.status === "CLOSED") { ctx.status = 403; ctx.body = "Chat is closed"; return; }
+    if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { ctx.status = 403; ctx.body = "Invite-only chat"; return; }
+    if (chat.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(chat.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
+      return;
+    }
     try {
       await chatsModel.joinChat(ctx.params.id);
     } catch (_) {}
@@ -3957,7 +4096,11 @@ router
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {};
     const tribeId = b.tribeId || null;
-    if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
+    if (tribeId) {
+      const t = await tribesModel.getTribeById(tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+      await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
+    }
     const msg = await padsModel.createPad(
       stripDangerousTags(b.title || ""),
       b.status || "OPEN",
@@ -4006,6 +4149,18 @@ router
   .post("/pads/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const uid = getViewerId();
+    const pad = await padsModel.getPadById(ctx.params.id);
+    if (!pad) { ctx.status = 404; ctx.body = "Pad not found"; return; }
+    if (pad.isClosed || pad.status === "CLOSED") { ctx.status = 403; ctx.body = "Pad is closed"; return; }
+    if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { ctx.status = 403; ctx.body = "Invite-only pad"; return; }
+    if (pad.tribeId) {
+      try {
+        const t = await tribesModel.getTribeById(pad.tribeId);
+        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+      ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
+      return;
+    }
     await padsModel.addMemberToPad(ctx.params.id, uid);
     ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
   })
@@ -4030,6 +4185,10 @@ router
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {};
     const tribeId = b.tribeId || null;
+    if (tribeId) {
+      const t = await tribesModel.getTribeById(tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
     const intervalYearly  = [].concat(b.intervalYearly).includes("1");
@@ -4053,6 +4212,11 @@ router
   })
   .post("/calendars/update/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     const b = ctx.request.body || {};
     try {
       await calendarsModel.updateCalendarById(ctx.params.id, {
@@ -4066,16 +4230,31 @@ router
   })
   .post("/calendars/delete/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     try { await calendarsModel.deleteCalendarById(ctx.params.id); } catch (_) {}
     ctx.redirect('/calendars');
   })
   .post("/calendars/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {}
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
   })
   .post("/calendars/leave/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
+    if (target && target.tribeId) {
+      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+      if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+    }
     try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {}
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
   })
@@ -4128,12 +4307,26 @@ router
   .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const calendarId = (ctx.request.body || {}).calendarId || "";
+    if (calendarId) {
+      const target = await calendarsModel.getCalendarById(calendarId).catch(() => null);
+      if (target && target.tribeId) {
+        const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+        if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+      }
+    }
     try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {}
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
   })
   .post("/calendars/delete-date/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const calendarId = (ctx.request.body || {}).calendarId || "";
+    if (calendarId) {
+      const target = await calendarsModel.getCalendarById(calendarId).catch(() => null);
+      if (target && target.tribeId) {
+        const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+        if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
+      }
+    }
     try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {}
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
   })
@@ -4580,6 +4773,23 @@ const middleware = [
   routes,
 ];
 const app = http({ host, port, middleware, allowHost: config.allowHost });
-app._close = () => { nameWarmup.close(); cooler.close(); };
+
+let pubEngineTimer = null;
+async function runPubEngineTick() {
+  if (!bankingModel.isPubNode()) return;
+  try { await bankingModel.executeEpoch({}); } catch (_) {}
+  try { await bankingModel.processPendingClaims(); } catch (_) {}
+  try { await bankingModel.publishPubAvailability(); } catch (_) {}
+}
+if (bankingModel.isPubNode()) {
+  setTimeout(() => { runPubEngineTick(); }, 15000);
+  pubEngineTimer = setInterval(runPubEngineTick, 30 * 60 * 1000);
+}
+
+app._close = () => {
+  if (pubEngineTimer) clearInterval(pubEngineTimer);
+  nameWarmup.close();
+  cooler.close();
+};
 module.exports = app;
 if (config.open === true) open(url);

+ 42 - 48
src/client/assets/styles/style.css

@@ -3642,17 +3642,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   font-style: italic;
 }
 
-.tribe-card-subtribes {
-  border: 1px solid #555;
-  border-radius: 4px;
-  padding: 8px;
-  margin: 6px 0;
-  background: #1e1f23;
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
 .tribe-card-members {
   border: none;
   border-radius: 4px;
@@ -4154,43 +4143,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   align-self: flex-end;
 }
 
-.tribe-parent-box {
-  margin-bottom: 12px;
-  text-align: center;
-}
-
-.tribe-parent-box h2 {
-  margin: 0 0 8px 0;
-}
-
-.tribe-parent-link {
-  display: block;
-}
-
-.tribe-parent-image {
-  width: 100%;
-  max-width: 200px;
-  border-radius: 8px;
-  border: 2px solid #444;
-}
-
-.tribe-card-parent {
-  padding: 8px 12px;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.tribe-parent-card-link {
-  color: #FFA500;
-  font-weight: bold;
-  text-decoration: none;
-}
-
-.tribe-parent-card-link:hover {
-  text-decoration: underline;
-}
-
 .comment-submit-btn {
   width: auto;
   max-width: 200px;
@@ -5057,3 +5009,45 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all}
 .logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0}
 .logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
+.no-border{border:none}
+.tribe-card-padded{padding:12px 16px}
+.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
+.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
+.bold{font-weight:bold}
+.inline-form{display:inline}
+.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
+.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
+.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
+.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
+.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
+.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
+.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
+.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
+.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
+.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
+.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
+.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
+.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
+.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
+.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
+.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
+.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
+.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
+.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
+.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
+.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
+.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
+.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
+.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
+.stats-link{color:#007bff;text-decoration:none}
+.stats-link-break{color:#007bff;text-decoration:none;word-break:break-all}
+.stats-muted-555{color:#555}
+.stats-muted-888{color:#888}
+.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
+.stats-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600}
+.stats-list-reset{list-style-type:none;padding:0;margin:0}
+.stats-mb-16{margin-bottom:16px}
+.stats-w-100{width:100%}
+.stats-table{width:100%;border-collapse:collapse}
+.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
+.stats-h-row{font-size:18px;color:#555;margin:8px 0}

+ 0 - 3
src/client/assets/themes/Clear-SNH.css

@@ -428,7 +428,6 @@ a.user-link:focus {
 .tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
 .tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
 .tribe-info-empty { color: #999 !important; }
-.tribe-card-subtribes { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
 .tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
 .tribe-members-count { color: #FF6F00 !important; }
 .tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
@@ -440,5 +439,3 @@ a.user-link:focus {
 .tribe-subtribe-link:hover { background: #E0E0E0 !important; }
 .tribe-parent-image { border-color: #E0E0E0 !important; }
 .tribe-parent-box { background: #FFFFFF !important; }
-.tribe-card-parent { background: #F8F8F8 !important; border-color: #E0E0E0 !important; }
-.tribe-parent-card-link { color: #FF6F00 !important; }

+ 0 - 3
src/client/assets/themes/Dark-SNH.css

@@ -343,7 +343,6 @@ a.user-link:focus {
 .tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
 .tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
 .tribe-info-empty { color: #9aa3b2 !important; }
-.tribe-card-subtribes { border-color: #444 !important; background: #1e1f23 !important; }
 .tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
 .tribe-members-count { color: #ffa300 !important; }
 .tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
@@ -355,5 +354,3 @@ a.user-link:focus {
 .tribe-subtribe-link:hover { background: #333 !important; }
 .tribe-parent-image { border-color: #ffa300 !important; }
 .tribe-parent-box { background: #1e1f23 !important; }
-.tribe-card-parent { background: #1e1f23 !important; border-color: #444 !important; }
-.tribe-parent-card-link { color: #ffa300 !important; }

+ 0 - 3
src/client/assets/themes/Matrix-SNH.css

@@ -439,7 +439,6 @@ a.user-link:focus {
 .tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-info-empty { color: #006600 !important; }
-.tribe-card-subtribes { border-color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-members-count { color: #00FF00 !important; }
 .tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
@@ -451,5 +450,3 @@ a.user-link:focus {
 .tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-box { background: #1A1A1A !important; }
-.tribe-card-parent { background: #1A1A1A !important; border-color: #00FF00 !important; }
-.tribe-parent-card-link { color: #00FF00 !important; }

+ 0 - 3
src/client/assets/themes/Purple-SNH.css

@@ -474,7 +474,6 @@ a.user-link:focus {
 .tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
 .tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
 .tribe-info-empty { color: #8844aa !important; }
-.tribe-card-subtribes { border-color: #B86ADE !important; background: #2D0B47 !important; }
 .tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
 .tribe-members-count { color: #FFD600 !important; }
 .tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
@@ -486,5 +485,3 @@ a.user-link:focus {
 .tribe-subtribe-link:hover { background: #5A1A85 !important; }
 .tribe-parent-image { border-color: #B86ADE !important; }
 .tribe-parent-box { background: #3C1360 !important; }
-.tribe-card-parent { background: #4B1A72 !important; border-color: #B86ADE !important; }
-.tribe-parent-card-link { color: #FFD600 !important; }

+ 24 - 0
src/client/assets/translations/oasis_ar.js

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "القبائل الأخيرة",
     tribeTopSectionTitle: "القبائل الشائعة",
     tribeviewTribeButton: "زيارة القبيلة",
+    tribeviewSubTribeButton: "زيارة القبيلة الفرعية",
+    tribeRootLabel: "الجذر",
     tribeDescription: "استكشف أو أنشئ قبائل في شبكتك.",
     tribeFilterAll: "الكل",
     tribeFilterMine: "خاصتي",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "الحالة",
     tribeSubTribes: "القبائل الفرعية",
     tribeSubTribesCreate: "إنشاء قبيلة فرعية",
+    tribeSubTribesStrictDenied: "الوضع الصارم للقبيلة لا يسمح لك بإنشاء قبائل فرعية جديدة. يرجى الاتصال بالمسؤول.",
     tribeSubTribesEmpty: "لم يتم إنشاء قبائل فرعية بعد.",
     tribeLarpCreateForbidden: "لا يمكن إنشاء قبائل L.A.R.P.",
     tribeLarpUpdateForbidden: "لا يمكن تحديث قبائل L.A.R.P.",
@@ -2867,6 +2870,7 @@ module.exports = {
     padNoEntries: "لا توجد مدخلات بعد.",
     padAllSectionTitle: "جميع الوسادات",
     padMineSectionTitle: "وساداتي",
+    padsDescription: "إدارة محرّرات النصوص التعاونية المشفّرة في شبكتك.",
     padRecentSectionTitle: "الوسادات الأخيرة",
     padOpenSectionTitle: "الوسادات المفتوحة",
     padClosedSectionTitle: "الوسادات المغلقة",
@@ -3129,6 +3133,26 @@ module.exports = {
     tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.",
     tribeGovernanceNoGov: "لا توجد حكومة نشطة",
     tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.",
+    tribeGovCardTitle: "الحكومة الحالية",
+    tribeGovCycleSince: "بداية الدورة",
+    tribeGovCycleEnd: "نهاية الدورة",
+    tribeGovTimeRemaining: "الوقت المتبقي",
+    tribeGovPopulation: "السكان",
+    tribeGovMethod: "الطريقة",
+    tribeGovVotesReceived: "الأصوات المستلمة",
+    tribeGovLeader: "القائد",
+    tribeGovFilterGovernment: "الحكومة",
+    tribeGovFilterCandidatures: "الترشيحات",
+    tribeGovFilterLaws: "القوانين",
+    tribeGovCandidatureId: "الترشيح",
+    tribeGovCandidatureMethod: "الطريقة",
+    tribeGovCandidatureProposeBtn: "نشر الترشيح",
+    tribeGovRuleTitle: "عنوان القاعدة",
+    tribeGovRuleBody: "محتوى القاعدة",
+    tribeGovProposals: "الاقتراحات",
+    tribeGovRevocations: "الإلغاءات",
+    tribeGovHistorical: "السجل",
+    tribeGovRules: "القواعد",
     tribeGovernanceAlreadyPublished: "لدى هذه القبيلة ترشح مفتوح.",
     tribeGovernanceProposeInternal: "اقتراح ترشح داخلي",
     tribeGovernanceInternalCandidatures: "الترشيحات الداخلية",

+ 24 - 0
src/client/assets/translations/oasis_de.js

@@ -1657,6 +1657,8 @@ module.exports = {
     tribeRecentSectionTitle: "Neueste Stämme",
     tribeTopSectionTitle: "Beliebte Stämme",
     tribeviewTribeButton: "Stamm besuchen",
+    tribeviewSubTribeButton: "Unter-Stamm besuchen",
+    tribeRootLabel: "WURZEL",
     tribeDescription: "Stämme in deinem Netzwerk erkunden oder erstellen.",
     tribeFilterAll: "ALLE",
     tribeFilterMine: "MEINE",
@@ -1851,6 +1853,7 @@ module.exports = {
     tribeStatusLabel: "Status",
     tribeSubTribes: "UNTER-STÄMME",
     tribeSubTribesCreate: "Unter-Stamm Erstellen",
+    tribeSubTribesStrictDenied: "Der strikte Modus des Stammes erlaubt Ihnen nicht, neue Unter-Stämme zu erstellen. Bitte wenden Sie sich an den Administrator.",
     tribeSubTribesEmpty: "Noch keine Unter-Stämme erstellt.",
     tribeLarpCreateForbidden: "L.A.R.P.-Stämme können nicht erstellt werden.",
     tribeLarpUpdateForbidden: "L.A.R.P.-Stämme können nicht aktualisiert werden.",
@@ -2867,6 +2870,7 @@ module.exports = {
     padNoEntries: "Noch keine Einträge.",
     padAllSectionTitle: "Alle Pads",
     padMineSectionTitle: "Meine Pads",
+    padsDescription: "Verwalte kollaborative verschlüsselte Texteditoren in deinem Netzwerk.",
     padRecentSectionTitle: "Aktuelle Pads",
     padOpenSectionTitle: "Offene Pads",
     padClosedSectionTitle: "Geschlossene Pads",
@@ -3125,6 +3129,26 @@ module.exports = {
     tribeGovernanceDesc: "Interne Governance dieses Stammes.",
     tribeGovernanceNoGov: "Keine aktive Regierung",
     tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.",
+    tribeGovCardTitle: "Aktuelle Regierung",
+    tribeGovCycleSince: "ZYKLUS SEIT",
+    tribeGovCycleEnd: "ZYKLUS ENDE",
+    tribeGovTimeRemaining: "VERBLEIBENDE ZEIT",
+    tribeGovPopulation: "BEVÖLKERUNG",
+    tribeGovMethod: "METHODE",
+    tribeGovVotesReceived: "ERHALTENE STIMMEN",
+    tribeGovLeader: "ANFÜHRER",
+    tribeGovFilterGovernment: "REGIERUNG",
+    tribeGovFilterCandidatures: "KANDIDATUREN",
+    tribeGovFilterLaws: "GESETZE",
+    tribeGovCandidatureId: "Kandidatur",
+    tribeGovCandidatureMethod: "Methode",
+    tribeGovCandidatureProposeBtn: "Kandidatur veröffentlichen",
+    tribeGovRuleTitle: "Regeltitel",
+    tribeGovRuleBody: "Regelinhalt",
+    tribeGovProposals: "VORSCHLÄGE",
+    tribeGovRevocations: "WIDERRUFE",
+    tribeGovHistorical: "VERLAUF",
+    tribeGovRules: "REGELN",
     tribeGovernanceAlreadyPublished: "Dieser Stamm hat bereits eine offene Kandidatur.",
     tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen",
     tribeGovernanceInternalCandidatures: "Interne Kandidaturen",

+ 23 - 0
src/client/assets/translations/oasis_en.js

@@ -1663,6 +1663,8 @@ module.exports = {
     tribeRecentSectionTitle: "Recent Tribes",
     tribeTopSectionTitle: "Popular Tribes",
     tribeviewTribeButton: "Visit Tribe",
+    tribeviewSubTribeButton: "Visit Sub-Tribe",
+    tribeRootLabel: "ROOT",
     tribeDescription: "Explore or create tribes on your network.",
     tribeFilterAll: "ALL",
     tribeFilterMine: "MINE",
@@ -1857,6 +1859,7 @@ module.exports = {
     tribeStatusLabel: "Status",
     tribeSubTribes: "SUB-TRIBES",
     tribeSubTribesCreate: "Create Sub-Tribe",
+    tribeSubTribesStrictDenied: "Tribe strict mode does not allow you to create new sub-tribes. Please contact the administrator.",
     tribeSubTribesEmpty: "No sub-tribes created, yet.",
     tribeLarpCreateForbidden: "L.A.R.P. tribes cannot be created.",
     tribeLarpUpdateForbidden: "L.A.R.P. tribes cannot be updated.",
@@ -3148,6 +3151,26 @@ module.exports = {
     tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.",
     tribeGovernanceNoGov: "No active government",
     tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.",
+    tribeGovCardTitle: "Current Government",
+    tribeGovCycleSince: "CYCLE SINCE",
+    tribeGovCycleEnd: "CYCLE END",
+    tribeGovTimeRemaining: "TIME REMAINING",
+    tribeGovPopulation: "POPULATION",
+    tribeGovMethod: "METHOD",
+    tribeGovVotesReceived: "VOTES RECEIVED",
+    tribeGovLeader: "LEADER",
+    tribeGovFilterGovernment: "GOVERNMENT",
+    tribeGovFilterCandidatures: "CANDIDATURES",
+    tribeGovFilterLaws: "LAWS",
+    tribeGovCandidatureId: "Candidature",
+    tribeGovCandidatureMethod: "Method",
+    tribeGovCandidatureProposeBtn: "Publish Candidature",
+    tribeGovRuleTitle: "Rule Title",
+    tribeGovRuleBody: "Rule Body",
+    tribeGovProposals: "PROPOSALS",
+    tribeGovRevocations: "REVOCATIONS",
+    tribeGovHistorical: "HISTORICAL",
+    tribeGovRules: "RULES",
     tribeGovernanceAlreadyPublished: "This tribe already has an open candidature in the current global parliament cycle.",
     tribeGovernanceProposeInternal: "Propose internal candidature",
     tribeGovernanceInternalCandidatures: "Internal candidatures",

+ 24 - 0
src/client/assets/translations/oasis_es.js

@@ -1653,6 +1653,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribus Recientes",
     tribeTopSectionTitle: "Tribus Populares",
     tribeviewTribeButton: "Visitar Tribu",
+    tribeviewSubTribeButton: "Visitar Sub-Tribu",
+    tribeRootLabel: "RAÍZ",
     tribeDescription: "Explora o crea tribus en tu red.",
     tribeFilterAll: "TODOS",
     tribeFilterMine: "MÍAS",
@@ -1847,6 +1849,7 @@ module.exports = {
     tribeStatusLabel: "Estado",
     tribeSubTribes: "SUB-TRIBUS",
     tribeSubTribesCreate: "Crear Sub-Tribu",
+    tribeSubTribesStrictDenied: "El modo estricto de la tribu no te permite generar nuevas sub-tribus. Ponte en contacto con el administrador.",
     tribeSubTribesEmpty: "No se han creado sub-tribus, aún.",
     tribeLarpCreateForbidden: "No se pueden crear tribus L.A.R.P.",
     tribeLarpUpdateForbidden: "No se pueden actualizar tribus L.A.R.P.",
@@ -2876,6 +2879,7 @@ module.exports = {
     padNoEntries: "Sin entradas aún.",
     padAllSectionTitle: "Todos los Pads",
     padMineSectionTitle: "Mis Pads",
+    padsDescription: "Gestiona editores de texto cifrados colaborativos en tu red.",
     padRecentSectionTitle: "Pads Recientes",
     padOpenSectionTitle: "Pads Abiertos",
     padClosedSectionTitle: "Pads Cerrados",
@@ -3139,6 +3143,26 @@ module.exports = {
     tribeGovernanceDesc: "Gobierno interno de esta tribu.",
     tribeGovernanceNoGov: "Sin gobierno activo",
     tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.",
+    tribeGovCardTitle: "Gobierno Actual",
+    tribeGovCycleSince: "CICLO INICIO",
+    tribeGovCycleEnd: "CICLO FIN",
+    tribeGovTimeRemaining: "TIEMPO RESTANTE",
+    tribeGovPopulation: "POBLACIÓN",
+    tribeGovMethod: "MÉTODO",
+    tribeGovVotesReceived: "VOTOS RECIBIDOS",
+    tribeGovLeader: "LÍDER",
+    tribeGovFilterGovernment: "GOBIERNO",
+    tribeGovFilterCandidatures: "CANDIDATURAS",
+    tribeGovFilterLaws: "LEYES",
+    tribeGovCandidatureId: "Candidatura",
+    tribeGovCandidatureMethod: "Método",
+    tribeGovCandidatureProposeBtn: "Publicar Candidatura",
+    tribeGovRuleTitle: "Título de Regla",
+    tribeGovRuleBody: "Cuerpo de Regla",
+    tribeGovProposals: "PROPUESTAS",
+    tribeGovRevocations: "REVOCACIONES",
+    tribeGovHistorical: "HISTÓRICO",
+    tribeGovRules: "REGLAS",
     tribeGovernanceAlreadyPublished: "Esta tribu ya tiene una candidatura abierta en el ciclo actual del parlamento global.",
     tribeGovernanceProposeInternal: "Proponer candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",

+ 24 - 0
src/client/assets/translations/oasis_eu.js

@@ -1620,6 +1620,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeTopSectionTitle: "Tribu Gorenak",
     tribeviewTribeButton: "Tribua Bisitatu",
+    tribeviewSubTribeButton: "Azpi-Tribua Bisitatu",
+    tribeRootLabel: "ERROA",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeFilterAll: "GUZTIAK",
     tribeFilterMine: "NEUREAK",
@@ -1814,6 +1816,7 @@ module.exports = {
     tribeStatusLabel: "Egoera",
     tribeSubTribes: "AZPI-TRIBUAK",
     tribeSubTribesCreate: "Azpi-Tribua Sortu",
+    tribeSubTribesStrictDenied: "Tribuaren modu zorrotzak ez dizu azpi-tribu berriak sortzen uzten. Jarri harremanetan administratzailearekin.",
     tribeSubTribesEmpty: "Ez da azpi-triburik sortu, oraindik.",
     tribeLarpCreateForbidden: "L.A.R.P. tribuak ezin dira sortu.",
     tribeLarpUpdateForbidden: "L.A.R.P. tribuak ezin dira eguneratu.",
@@ -2837,6 +2840,7 @@ module.exports = {
     padNoEntries: "Oraindik sarrerarik ez.",
     padAllSectionTitle: "Pad Guztiak",
     padMineSectionTitle: "Nire Padak",
+    padsDescription: "Kudeatu zure sareko enkriptatutako testu-editore kolaboratiboak.",
     padRecentSectionTitle: "Azken Padak",
     padOpenSectionTitle: "Pad Irekiak",
     padClosedSectionTitle: "Pad Itxiak",
@@ -3099,6 +3103,26 @@ module.exports = {
     tribeGovernanceDesc: "Tribu honen barne gobernantza.",
     tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
     tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.",
+    tribeGovCardTitle: "Egungo Gobernua",
+    tribeGovCycleSince: "ZIKLOAREN HASIERA",
+    tribeGovCycleEnd: "ZIKLOAREN AMAIERA",
+    tribeGovTimeRemaining: "GERATZEN DEN DENBORA",
+    tribeGovPopulation: "BIZTANLERIA",
+    tribeGovMethod: "METODOA",
+    tribeGovVotesReceived: "JASOTAKO BOTOAK",
+    tribeGovLeader: "BURUZAGIA",
+    tribeGovFilterGovernment: "GOBERNUA",
+    tribeGovFilterCandidatures: "HAUTAGAITZAK",
+    tribeGovFilterLaws: "LEGEAK",
+    tribeGovCandidatureId: "Hautagaitza",
+    tribeGovCandidatureMethod: "Metodoa",
+    tribeGovCandidatureProposeBtn: "Hautagaitza argitaratu",
+    tribeGovRuleTitle: "Arauaren izenburua",
+    tribeGovRuleBody: "Arauaren edukia",
+    tribeGovProposals: "PROPOSAMENAK",
+    tribeGovRevocations: "EZEZTAPENAK",
+    tribeGovHistorical: "HISTORIKOA",
+    tribeGovRules: "ARAUAK",
     tribeGovernanceAlreadyPublished: "Tribu honek jada hautagai ireki bat du.",
     tribeGovernanceProposeInternal: "Proposatu barne hautagaia",
     tribeGovernanceInternalCandidatures: "Barne hautagaiak",

+ 24 - 0
src/client/assets/translations/oasis_fr.js

@@ -1645,6 +1645,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribus récentes",
     tribeTopSectionTitle: "Tribus populaires",
     tribeviewTribeButton: "Visiter la tribu",
+    tribeviewSubTribeButton: "Visiter la sous-tribu",
+    tribeRootLabel: "RACINE",
     tribeDescription: "Explorez ou créez des tribus dans votre réseau.",
     tribeFilterAll: "TOUS",
     tribeFilterMine: "MIENS",
@@ -1839,6 +1841,7 @@ module.exports = {
     tribeStatusLabel: "Statut",
     tribeSubTribes: "SOUS-TRIBUS",
     tribeSubTribesCreate: "Créer Sous-Tribu",
+    tribeSubTribesStrictDenied: "Le mode strict de la tribu ne vous permet pas de créer de nouvelles sous-tribus. Veuillez contacter l'administrateur.",
     tribeSubTribesEmpty: "Aucune sous-tribu créée, pour l'instant.",
     tribeLarpCreateForbidden: "Les tribus L.A.R.P. ne peuvent pas être créées.",
     tribeLarpUpdateForbidden: "Les tribus L.A.R.P. ne peuvent pas être mises à jour.",
@@ -2865,6 +2868,7 @@ module.exports = {
     padNoEntries: "Aucune entrée pour l'instant.",
     padAllSectionTitle: "Tous les Pads",
     padMineSectionTitle: "Mes Pads",
+    padsDescription: "Gérez des éditeurs de texte chiffrés collaboratifs dans votre réseau.",
     padRecentSectionTitle: "Pads Récents",
     padOpenSectionTitle: "Pads Ouverts",
     padClosedSectionTitle: "Pads Fermés",
@@ -3127,6 +3131,26 @@ module.exports = {
     tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
     tribeGovernanceNoGov: "Pas de gouvernement actif",
     tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.",
+    tribeGovCardTitle: "Gouvernement Actuel",
+    tribeGovCycleSince: "DÉBUT DU CYCLE",
+    tribeGovCycleEnd: "FIN DU CYCLE",
+    tribeGovTimeRemaining: "TEMPS RESTANT",
+    tribeGovPopulation: "POPULATION",
+    tribeGovMethod: "MÉTHODE",
+    tribeGovVotesReceived: "VOTES REÇUS",
+    tribeGovLeader: "DIRIGEANT",
+    tribeGovFilterGovernment: "GOUVERNEMENT",
+    tribeGovFilterCandidatures: "CANDIDATURES",
+    tribeGovFilterLaws: "LOIS",
+    tribeGovCandidatureId: "Candidature",
+    tribeGovCandidatureMethod: "Méthode",
+    tribeGovCandidatureProposeBtn: "Publier la candidature",
+    tribeGovRuleTitle: "Titre de la règle",
+    tribeGovRuleBody: "Corps de la règle",
+    tribeGovProposals: "PROPOSITIONS",
+    tribeGovRevocations: "RÉVOCATIONS",
+    tribeGovHistorical: "HISTORIQUE",
+    tribeGovRules: "RÈGLES",
     tribeGovernanceAlreadyPublished: "Cette tribu a déjà une candidature ouverte dans le cycle actuel.",
     tribeGovernanceProposeInternal: "Proposer une candidature interne",
     tribeGovernanceInternalCandidatures: "Candidatures internes",

+ 24 - 0
src/client/assets/translations/oasis_hi.js

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "हाल की जनजातियाँ",
     tribeTopSectionTitle: "लोकप्रिय जनजातियाँ",
     tribeviewTribeButton: "जनजाति देखें",
+    tribeviewSubTribeButton: "उप-जनजाति देखें",
+    tribeRootLabel: "मूल",
     tribeDescription: "अपने नेटवर्क में जनजातियाँ खोजें या बनाएँ।",
     tribeFilterAll: "सभी",
     tribeFilterMine: "मेरे",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "स्थिति",
     tribeSubTribes: "उप-जनजातियाँ",
     tribeSubTribesCreate: "उप-जनजाति बनाएँ",
+    tribeSubTribesStrictDenied: "जनजाति का सख्त मोड आपको नई उप-जनजातियाँ बनाने की अनुमति नहीं देता। कृपया प्रशासक से संपर्क करें।",
     tribeSubTribesEmpty: "अभी तक कोई उप-जनजाति नहीं बनाई गई।",
     tribeLarpCreateForbidden: "L.A.R.P. जनजातियाँ नहीं बनाई जा सकतीं।",
     tribeLarpUpdateForbidden: "L.A.R.P. जनजातियाँ अपडेट नहीं की जा सकतीं।",
@@ -2867,6 +2870,7 @@ module.exports = {
     padNoEntries: "अभी कोई प्रविष्टि नहीं।",
     padAllSectionTitle: "सभी पैड्स",
     padMineSectionTitle: "मेरे पैड्स",
+    padsDescription: "अपने नेटवर्क में सहयोगी एन्क्रिप्टेड टेक्स्ट संपादक प्रबंधित करें।",
     padRecentSectionTitle: "हालिया पैड्स",
     padOpenSectionTitle: "खुले पैड्स",
     padClosedSectionTitle: "बंद पैड्स",
@@ -3129,6 +3133,26 @@ module.exports = {
     tribeGovernanceDesc: "इस जनजाति का आंतरिक शासन।",
     tribeGovernanceNoGov: "कोई सक्रिय सरकार नहीं",
     tribeGovernanceNoGovDesc: "इस जनजाति ने अभी सरकार नहीं चुनी।",
+    tribeGovCardTitle: "वर्तमान सरकार",
+    tribeGovCycleSince: "चक्र शुरू",
+    tribeGovCycleEnd: "चक्र समाप्त",
+    tribeGovTimeRemaining: "शेष समय",
+    tribeGovPopulation: "जनसंख्या",
+    tribeGovMethod: "विधि",
+    tribeGovVotesReceived: "प्राप्त वोट",
+    tribeGovLeader: "नेता",
+    tribeGovFilterGovernment: "सरकार",
+    tribeGovFilterCandidatures: "उम्मीदवारी",
+    tribeGovFilterLaws: "कानून",
+    tribeGovCandidatureId: "उम्मीदवारी",
+    tribeGovCandidatureMethod: "विधि",
+    tribeGovCandidatureProposeBtn: "उम्मीदवारी प्रकाशित करें",
+    tribeGovRuleTitle: "नियम का शीर्षक",
+    tribeGovRuleBody: "नियम का शरीर",
+    tribeGovProposals: "प्रस्ताव",
+    tribeGovRevocations: "निरसन",
+    tribeGovHistorical: "इतिहास",
+    tribeGovRules: "नियम",
     tribeGovernanceAlreadyPublished: "इस जनजाति की पहले से ही एक खुली उम्मीदवारी है।",
     tribeGovernanceProposeInternal: "आंतरिक उम्मीदवारी प्रस्तावित करें",
     tribeGovernanceInternalCandidatures: "आंतरिक उम्मीदवारियां",

+ 24 - 0
src/client/assets/translations/oasis_it.js

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribù recenti",
     tribeTopSectionTitle: "Tribù popolari",
     tribeviewTribeButton: "Visita tribù",
+    tribeviewSubTribeButton: "Visita Sotto-Tribù",
+    tribeRootLabel: "RADICE",
     tribeDescription: "Esplora o crea tribù nella tua rete.",
     tribeFilterAll: "TUTTE",
     tribeFilterMine: "MIE",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "Stato",
     tribeSubTribes: "SOTTO-TRIBÙ",
     tribeSubTribesCreate: "Crea Sotto-Tribù",
+    tribeSubTribesStrictDenied: "La modalità rigida della tribù non ti consente di creare nuove sotto-tribù. Si prega di contattare l'amministratore.",
     tribeSubTribesEmpty: "Nessuna sotto-tribù creata.",
     tribeLarpCreateForbidden: "Le tribù L.A.R.P. non possono essere create.",
     tribeLarpUpdateForbidden: "Le tribù L.A.R.P. non possono essere aggiornate.",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "Nessuna voce ancora.",
     padAllSectionTitle: "Tutti i Pad",
     padMineSectionTitle: "I Miei Pad",
+    padsDescription: "Gestisci editor di testo collaborativi cifrati nella tua rete.",
     padRecentSectionTitle: "Pad Recenti",
     padOpenSectionTitle: "Pad Aperti",
     padClosedSectionTitle: "Pad Chiusi",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "Governance interna di questa tribù.",
     tribeGovernanceNoGov: "Nessun governo attivo",
     tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.",
+    tribeGovCardTitle: "Governo Attuale",
+    tribeGovCycleSince: "INIZIO CICLO",
+    tribeGovCycleEnd: "FINE CICLO",
+    tribeGovTimeRemaining: "TEMPO RIMANENTE",
+    tribeGovPopulation: "POPOLAZIONE",
+    tribeGovMethod: "METODO",
+    tribeGovVotesReceived: "VOTI RICEVUTI",
+    tribeGovLeader: "LEADER",
+    tribeGovFilterGovernment: "GOVERNO",
+    tribeGovFilterCandidatures: "CANDIDATURE",
+    tribeGovFilterLaws: "LEGGI",
+    tribeGovCandidatureId: "Candidatura",
+    tribeGovCandidatureMethod: "Metodo",
+    tribeGovCandidatureProposeBtn: "Pubblica candidatura",
+    tribeGovRuleTitle: "Titolo della regola",
+    tribeGovRuleBody: "Corpo della regola",
+    tribeGovProposals: "PROPOSTE",
+    tribeGovRevocations: "REVOCHE",
+    tribeGovHistorical: "STORICO",
+    tribeGovRules: "REGOLE",
     tribeGovernanceAlreadyPublished: "Questa tribù ha già una candidatura aperta.",
     tribeGovernanceProposeInternal: "Proponi candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidature interne",

+ 24 - 0
src/client/assets/translations/oasis_pt.js

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribos recentes",
     tribeTopSectionTitle: "Tribos populares",
     tribeviewTribeButton: "Visitar tribo",
+    tribeviewSubTribeButton: "Visitar sub-tribo",
+    tribeRootLabel: "RAIZ",
     tribeDescription: "Explora ou cria tribos na tua rede.",
     tribeFilterAll: "TODOS",
     tribeFilterMine: "MEUS",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "Estado",
     tribeSubTribes: "SUB-TRIBOS",
     tribeSubTribesCreate: "Criar Sub-Tribo",
+    tribeSubTribesStrictDenied: "O modo estrito da tribo não permite criar novas sub-tribos. Por favor, entre em contato com o administrador.",
     tribeSubTribesEmpty: "Ainda sem sub-tribos criadas.",
     tribeLarpCreateForbidden: "Tribos L.A.R.P. não podem ser criadas.",
     tribeLarpUpdateForbidden: "Tribos L.A.R.P. não podem ser atualizadas.",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "Sem entradas ainda.",
     padAllSectionTitle: "Todos os Pads",
     padMineSectionTitle: "Os Meus Pads",
+    padsDescription: "Gere editores de texto colaborativos cifrados na tua rede.",
     padRecentSectionTitle: "Pads Recentes",
     padOpenSectionTitle: "Pads Abertos",
     padClosedSectionTitle: "Pads Fechados",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "Governança interna desta tribo.",
     tribeGovernanceNoGov: "Sem governo ativo",
     tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.",
+    tribeGovCardTitle: "Governo Atual",
+    tribeGovCycleSince: "INÍCIO DO CICLO",
+    tribeGovCycleEnd: "FIM DO CICLO",
+    tribeGovTimeRemaining: "TEMPO RESTANTE",
+    tribeGovPopulation: "POPULAÇÃO",
+    tribeGovMethod: "MÉTODO",
+    tribeGovVotesReceived: "VOTOS RECEBIDOS",
+    tribeGovLeader: "LÍDER",
+    tribeGovFilterGovernment: "GOVERNO",
+    tribeGovFilterCandidatures: "CANDIDATURAS",
+    tribeGovFilterLaws: "LEIS",
+    tribeGovCandidatureId: "Candidatura",
+    tribeGovCandidatureMethod: "Método",
+    tribeGovCandidatureProposeBtn: "Publicar candidatura",
+    tribeGovRuleTitle: "Título da regra",
+    tribeGovRuleBody: "Corpo da regra",
+    tribeGovProposals: "PROPOSTAS",
+    tribeGovRevocations: "REVOGAÇÕES",
+    tribeGovHistorical: "HISTÓRICO",
+    tribeGovRules: "REGRAS",
     tribeGovernanceAlreadyPublished: "Esta tribo já tem candidatura aberta.",
     tribeGovernanceProposeInternal: "Propor candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",

+ 24 - 0
src/client/assets/translations/oasis_ru.js

@@ -1646,6 +1646,8 @@ module.exports = {
     tribeRecentSectionTitle: "Недавние племена",
     tribeTopSectionTitle: "Популярные племена",
     tribeviewTribeButton: "Посетить племя",
+    tribeviewSubTribeButton: "Посетить под-племя",
+    tribeRootLabel: "КОРЕНЬ",
     tribeDescription: "Исследуйте или создавайте племена в вашей сети.",
     tribeFilterAll: "ВСЕ",
     tribeFilterMine: "МОИ",
@@ -1840,6 +1842,7 @@ module.exports = {
     tribeStatusLabel: "Статус",
     tribeSubTribes: "ПОДПЛЕМЕНА",
     tribeSubTribesCreate: "Создать подплемя",
+    tribeSubTribesStrictDenied: "Строгий режим племени не позволяет создавать новые подплемена. Пожалуйста, свяжитесь с администратором.",
     tribeSubTribesEmpty: "Подплемён пока не создано.",
     tribeLarpCreateForbidden: "Племена L.A.R.P. нельзя создавать.",
     tribeLarpUpdateForbidden: "Племена L.A.R.P. нельзя обновлять.",
@@ -2830,6 +2833,7 @@ module.exports = {
     padNoEntries: "Записей пока нет.",
     padAllSectionTitle: "Все Пады",
     padMineSectionTitle: "Мои Пады",
+    padsDescription: "Управляйте совместными зашифрованными текстовыми редакторами в вашей сети.",
     padRecentSectionTitle: "Недавние Пады",
     padOpenSectionTitle: "Открытые Пады",
     padClosedSectionTitle: "Закрытые Пады",
@@ -3092,6 +3096,26 @@ module.exports = {
     tribeGovernanceDesc: "Внутреннее управление этим племенем.",
     tribeGovernanceNoGov: "Нет активного правительства",
     tribeGovernanceNoGovDesc: "Это племя ещё не выбрало правительство.",
+    tribeGovCardTitle: "Текущее правительство",
+    tribeGovCycleSince: "ЦИКЛ С",
+    tribeGovCycleEnd: "ЦИКЛ ДО",
+    tribeGovTimeRemaining: "ВРЕМЯ ОСТАЛОСЬ",
+    tribeGovPopulation: "НАСЕЛЕНИЕ",
+    tribeGovMethod: "МЕТОД",
+    tribeGovVotesReceived: "ПОЛУЧЕННЫЕ ГОЛОСА",
+    tribeGovLeader: "ЛИДЕР",
+    tribeGovFilterGovernment: "ПРАВИТЕЛЬСТВО",
+    tribeGovFilterCandidatures: "КАНДИДАТУРЫ",
+    tribeGovFilterLaws: "ЗАКОНЫ",
+    tribeGovCandidatureId: "Кандидатура",
+    tribeGovCandidatureMethod: "Метод",
+    tribeGovCandidatureProposeBtn: "Опубликовать кандидатуру",
+    tribeGovRuleTitle: "Название правила",
+    tribeGovRuleBody: "Содержание правила",
+    tribeGovProposals: "ПРЕДЛОЖЕНИЯ",
+    tribeGovRevocations: "ОТЗЫВЫ",
+    tribeGovHistorical: "ИСТОРИЯ",
+    tribeGovRules: "ПРАВИЛА",
     tribeGovernanceAlreadyPublished: "У этого племени уже открытая кандидатура.",
     tribeGovernanceProposeInternal: "Предложить внутреннюю кандидатуру",
     tribeGovernanceInternalCandidatures: "Внутренние кандидатуры",

+ 24 - 0
src/client/assets/translations/oasis_zh.js

@@ -1659,6 +1659,8 @@ module.exports = {
     tribeRecentSectionTitle: "最近的部落",
     tribeTopSectionTitle: "热门部落",
     tribeviewTribeButton: "访问部落",
+    tribeviewSubTribeButton: "访问子部落",
+    tribeRootLabel: "根",
     tribeDescription: "探索或创建你网络中的部落。",
     tribeFilterAll: "全部",
     tribeFilterMine: "我的",
@@ -1853,6 +1855,7 @@ module.exports = {
     tribeStatusLabel: "状态",
     tribeSubTribes: "子部落",
     tribeSubTribesCreate: "创建子部落",
+    tribeSubTribesStrictDenied: "部落的严格模式不允许您创建新的子部落。请联系管理员。",
     tribeSubTribesEmpty: "还没有创建子部落。",
     tribeLarpCreateForbidden: "无法创建 L.A.R.P. 部落。",
     tribeLarpUpdateForbidden: "无法更新 L.A.R.P. 部落。",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "暂无内容。",
     padAllSectionTitle: "全部协作板",
     padMineSectionTitle: "我的协作板",
+    padsDescription: "在您的网络中管理协作加密的文本编辑器。",
     padRecentSectionTitle: "最近协作板",
     padOpenSectionTitle: "开放协作板",
     padClosedSectionTitle: "关闭协作板",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "此部落的内部治理。",
     tribeGovernanceNoGov: "无活跃政府",
     tribeGovernanceNoGovDesc: "此部落尚未选举政府。",
+    tribeGovCardTitle: "当前政府",
+    tribeGovCycleSince: "周期开始",
+    tribeGovCycleEnd: "周期结束",
+    tribeGovTimeRemaining: "剩余时间",
+    tribeGovPopulation: "人口",
+    tribeGovMethod: "方法",
+    tribeGovVotesReceived: "收到的投票",
+    tribeGovLeader: "领导者",
+    tribeGovFilterGovernment: "政府",
+    tribeGovFilterCandidatures: "候选",
+    tribeGovFilterLaws: "法律",
+    tribeGovCandidatureId: "候选",
+    tribeGovCandidatureMethod: "方法",
+    tribeGovCandidatureProposeBtn: "发布候选",
+    tribeGovRuleTitle: "规则标题",
+    tribeGovRuleBody: "规则内容",
+    tribeGovProposals: "提案",
+    tribeGovRevocations: "撤销",
+    tribeGovHistorical: "历史",
+    tribeGovRules: "规则",
     tribeGovernanceAlreadyPublished: "此部落在当前周期已有候选人。",
     tribeGovernanceProposeInternal: "提议内部候选人",
     tribeGovernanceInternalCandidatures: "内部候选人",

+ 215 - 119
src/models/calendars_model.js

@@ -30,7 +30,7 @@ const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
   return out.sort((a, b) => a.getTime() - b.getTime())
 }
 
-module.exports = ({ cooler, pmModel }) => {
+module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
@@ -39,24 +39,38 @@ module.exports = ({ cooler, pmModel }) => {
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
     )
 
+  const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null
+  const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
+  const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
+  const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
+  const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}
+
   const buildIndex = (messages) => {
     const tomb = new Set()
     const nodes = new Map()
     const parent = new Map()
     const child = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
     for (const m of messages) {
       const k = m.key
       const v = m.value || {}
       const c = v.content
       if (!c) continue
-      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
       if (c.type === "calendar") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        authorByKey.set(k, v.author)
         if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
       }
     }
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target)
+      if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
+    }
+
     const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
     const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
 
@@ -71,21 +85,24 @@ module.exports = ({ cooler, pmModel }) => {
   const buildCalendar = (node, rootId) => {
     const c = node.c || {}
     if (c.type !== "calendar") return null
+    const undec = c.encryptedPayload && c._decrypted === false
     return {
       key: node.key,
       rootId,
-      title: safeText(c.title),
+      title: undec ? "" : safeText(c.title),
       status: c.status || "OPEN",
-      deadline: c.deadline || "",
+      deadline: undec ? "" : (c.deadline || ""),
       tags: Array.isArray(c.tags) ? c.tags : [],
       author: c.author || node.author,
       participants: Array.isArray(c.participants) ? c.participants : [],
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
-      tribeId: c.tribeId || null
+      tribeId: c.tribeId || null,
+      encrypted: !!undec
     }
   }
 
+
   const isClosed = (calendar) => {
     if (calendar.status === "CLOSED") return true
     if (!calendar.deadline) return false
@@ -126,7 +143,7 @@ module.exports = ({ cooler, pmModel }) => {
       if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
       if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
 
-      const content = {
+      let content = {
         type: "calendar",
         title: safeText(title),
         status: validStatus,
@@ -138,6 +155,7 @@ module.exports = ({ cooler, pmModel }) => {
         updatedAt: now,
         ...(tribeId ? { tribeId } : {})
       }
+      content = await encryptIfTribe(content)
 
       const calMsg = await new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
@@ -148,30 +166,36 @@ module.exports = ({ cooler, pmModel }) => {
 
       const allDateMsgs = []
       for (const d of dates) {
+        let dateContent = {
+          type: "calendarDate",
+          calendarId,
+          date: d.toISOString(),
+          label: safeText(firstDateLabel),
+          author: userId,
+          createdAt: new Date().toISOString(),
+          ...(tribeId ? { tribeId } : {})
+        }
+        dateContent = await encryptIfTribe(dateContent)
         const dateMsg = await new Promise((resolve, reject) => {
-          ssbClient.publish({
-            type: "calendarDate",
-            calendarId,
-            date: d.toISOString(),
-            label: safeText(firstDateLabel),
-            author: userId,
-            createdAt: new Date().toISOString()
-          }, (err, msg) => err ? reject(err) : resolve(msg))
+          ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
         })
         allDateMsgs.push(dateMsg)
       }
 
       if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
         for (const dateMsg of allDateMsgs) {
+          let noteContent = {
+            type: "calendarNote",
+            calendarId,
+            dateId: dateMsg.key,
+            text: safeText(firstNote),
+            author: userId,
+            createdAt: new Date().toISOString(),
+            ...(tribeId ? { tribeId } : {})
+          }
+          noteContent = await encryptIfTribe(noteContent)
           await new Promise((resolve, reject) => {
-            ssbClient.publish({
-              type: "calendarNote",
-              calendarId,
-              dateId: dateMsg.key,
-              text: safeText(firstNote),
-              author: userId,
-              createdAt: new Date().toISOString()
-            }, (err, msg) => err ? reject(err) : resolve(msg))
+            ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
           })
         }
       }
@@ -183,90 +207,112 @@ module.exports = ({ cooler, pmModel }) => {
       const tipId = await this.resolveCurrentId(id)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Calendar not found"))
-          if (item.content.author !== userId) return reject(new Error("Not the author"))
-          const c = item.content
-          const updated = {
-            ...c,
-            title: data.title !== undefined ? safeText(data.title) : c.title,
-            status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
-            deadline: data.deadline !== undefined ? data.deadline : c.deadline,
-            tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
-            updatedAt: new Date().toISOString(),
-            replaces: tipId
-          }
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e1) => {
-            if (e1) return reject(e1)
-            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
-          })
-        })
+      const item = await new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
       })
+      if (!item || !item.content) throw new Error("Calendar not found")
+      const oldDec = await decryptIfTribe(item.content)
+      assertReadable(oldDec, "Calendar")
+      if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
+      let updated = {
+        type: "calendar",
+        title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
+        status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
+        deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""),
+        tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
+        author: oldDec.author || userId,
+        participants: oldDec.participants || [userId],
+        ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
+        createdAt: oldDec.createdAt,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+      updated = await encryptIfTribe(updated)
+      const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
+      return result
     },
 
     async deleteCalendarById(id) {
       const tipId = await this.resolveCurrentId(id)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Calendar not found"))
-          if (item.content.author !== userId) return reject(new Error("Not the author"))
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
-        })
-      })
+      const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
+      if (!item || !item.content) throw new Error("Calendar not found")
+      const dec = await decryptIfTribe(item.content)
+      assertReadable(dec, "Calendar")
+      if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
     },
 
     async joinCalendar(calendarId) {
       const tipId = await this.resolveCurrentId(calendarId)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Calendar not found"))
-          const c = item.content
-          const participants = Array.isArray(c.participants) ? c.participants : []
-          if (participants.includes(userId)) return resolve()
-          const updated = { ...c, participants: [...participants, userId], updatedAt: new Date().toISOString(), replaces: tipId }
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e1) => {
-            if (e1) return reject(e1)
-            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
-          })
-        })
-      })
+      const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
+      if (!item || !item.content) throw new Error("Calendar not found")
+      const dec = await decryptIfTribe(item.content)
+      assertReadable(dec, "Calendar")
+      const participants = Array.isArray(dec.participants) ? dec.participants : []
+      if (participants.includes(userId)) return
+      let updated = {
+        type: "calendar",
+        title: dec.title || "",
+        status: dec.status || "OPEN",
+        deadline: dec.deadline || "",
+        tags: Array.isArray(dec.tags) ? dec.tags : [],
+        author: dec.author,
+        participants: [...participants, userId],
+        ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
+        createdAt: dec.createdAt,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+      updated = await encryptIfTribe(updated)
+      const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
+      return result
     },
 
     async leaveCalendar(calendarId) {
       const tipId = await this.resolveCurrentId(calendarId)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Calendar not found"))
-          const c = item.content
-          if (c.author === userId) return reject(new Error("Author cannot leave"))
-          const participants = Array.isArray(c.participants) ? c.participants : []
-          if (!participants.includes(userId)) return resolve()
-          const updated = { ...c, participants: participants.filter(p => p !== userId), updatedAt: new Date().toISOString(), replaces: tipId }
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e1) => {
-            if (e1) return reject(e1)
-            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
-          })
-        })
-      })
+      const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
+      if (!item || !item.content) throw new Error("Calendar not found")
+      const dec = await decryptIfTribe(item.content)
+      assertReadable(dec, "Calendar")
+      if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
+      const participants = Array.isArray(dec.participants) ? dec.participants : []
+      if (!participants.includes(userId)) return
+      let updated = {
+        type: "calendar",
+        title: dec.title || "",
+        status: dec.status || "OPEN",
+        deadline: dec.deadline || "",
+        tags: Array.isArray(dec.tags) ? dec.tags : [],
+        author: dec.author,
+        participants: participants.filter(p => p !== userId),
+        ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
+        createdAt: dec.createdAt,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+      updated = await encryptIfTribe(updated)
+      const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
+      return result
     },
 
     async getCalendarById(id) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
+      await decryptIndexNodes(idx)
       let tip = id
       while (idx.child.has(tip)) tip = idx.child.get(tip)
       if (idx.tomb.has(tip)) return null
@@ -285,6 +331,7 @@ module.exports = ({ cooler, pmModel }) => {
       const uid = viewerId || ssbClient.id
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
+      await decryptIndexNodes(idx)
       const items = []
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
         if (idx.tomb.has(tipId)) continue
@@ -319,15 +366,18 @@ module.exports = ({ cooler, pmModel }) => {
       const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
       const allMsgs = []
       for (const d of dates) {
+        let dateContent = {
+          type: "calendarDate",
+          calendarId: rootId,
+          date: d.toISOString(),
+          label: safeText(label),
+          author: userId,
+          createdAt: new Date().toISOString(),
+          ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
+        }
+        dateContent = await encryptIfTribe(dateContent)
         const msg = await new Promise((resolve, reject) => {
-          ssbClient.publish({
-            type: "calendarDate",
-            calendarId: rootId,
-            date: d.toISOString(),
-            label: safeText(label),
-            author: userId,
-            createdAt: new Date().toISOString()
-          }, (err, m) => err ? reject(err) : resolve(m))
+          ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
         })
         allMsgs.push(msg)
       }
@@ -338,10 +388,15 @@ module.exports = ({ cooler, pmModel }) => {
       const rootId = await this.resolveRootId(calendarId)
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
+      const authorByKey = new Map()
+      for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
       const tombstoned = new Set()
       for (const m of messages) {
         const c = (m.value || {}).content
-        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+        if (c && c.type === "tombstone" && c.target) {
+          const targetAuthor = authorByKey.get(c.target)
+          if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
+        }
       }
       const dates = []
       for (const m of messages) {
@@ -350,13 +405,19 @@ module.exports = ({ cooler, pmModel }) => {
         const c = v.content
         if (!c || c.type !== "calendarDate") continue
         if (c.calendarId !== rootId) continue
+        let dec = c
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
+          dec = r && !r._undecryptable ? r : c
+          if (r && r._undecryptable) continue
+        }
         dates.push({
           key: m.key,
-          calendarId: c.calendarId,
-          date: c.date,
-          label: c.label || "",
-          author: c.author || v.author,
-          createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
+          calendarId: dec.calendarId || c.calendarId,
+          date: dec.date,
+          label: dec.label || "",
+          author: dec.author || v.author,
+          createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
         })
       }
       dates.sort((a, b) => new Date(a.date) - new Date(b.date))
@@ -370,10 +431,15 @@ module.exports = ({ cooler, pmModel }) => {
       const cal = await this.getCalendarById(rootId)
       if (!cal) throw new Error("Calendar not found")
       const messages = await readAll(ssbClient)
+      const authorByKey = new Map()
+      for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
       const tombstoned = new Set()
       for (const m of messages) {
         const c = (m.value || {}).content
-        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+        if (c && c.type === "tombstone" && c.target) {
+          const targetAuthor = authorByKey.get(c.target)
+          if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
+        }
       }
       let dateAuthor = null
       for (const m of messages) {
@@ -381,7 +447,12 @@ module.exports = ({ cooler, pmModel }) => {
         const c = (m.value || {}).content
         if (!c || c.type !== "calendarDate") continue
         if (tombstoned.has(m.key)) break
-        dateAuthor = c.author || (m.value || {}).author
+        let dec = c
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
+          if (r && !r._undecryptable) dec = r
+        }
+        dateAuthor = dec.author || (m.value || {}).author
         break
       }
       if (!dateAuthor) throw new Error("Date not found")
@@ -407,27 +478,30 @@ module.exports = ({ cooler, pmModel }) => {
       const cal = await this.getCalendarById(rootId)
       if (!cal) throw new Error("Calendar not found")
       if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
+      let noteContent = {
+        type: "calendarNote",
+        calendarId: rootId,
+        dateId,
+        text: safeText(text),
+        author: userId,
+        createdAt: new Date().toISOString(),
+        ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
+      }
+      noteContent = await encryptIfTribe(noteContent)
       return new Promise((resolve, reject) => {
-        ssbClient.publish({
-          type: "calendarNote",
-          calendarId: rootId,
-          dateId,
-          text: safeText(text),
-          author: userId,
-          createdAt: new Date().toISOString()
-        }, (err, msg) => err ? reject(err) : resolve(msg))
+        ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
       })
     },
 
     async deleteNote(noteId) {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
+      const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
+      if (!item || !item.content) throw new Error("Note not found")
+      const dec = await decryptIfTribe(item.content)
+      if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
       return new Promise((resolve, reject) => {
-        ssbClient.get(noteId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Note not found"))
-          if (item.content.author !== userId) return reject(new Error("Not the author"))
-          ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
-        })
+        ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
       })
     },
 
@@ -435,10 +509,15 @@ module.exports = ({ cooler, pmModel }) => {
       const rootId = await this.resolveRootId(calendarId)
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
+      const authorByKey = new Map()
+      for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
       const tombstoned = new Set()
       for (const m of messages) {
         const c = (m.value || {}).content
-        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+        if (c && c.type === "tombstone" && c.target) {
+          const targetAuthor = authorByKey.get(c.target)
+          if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
+        }
       }
       const notes = []
       for (const m of messages) {
@@ -447,13 +526,19 @@ module.exports = ({ cooler, pmModel }) => {
         if (!c || c.type !== "calendarNote") continue
         if (tombstoned.has(m.key)) continue
         if (c.calendarId !== rootId || c.dateId !== dateId) continue
+        let dec = c
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
+          if (r && !r._undecryptable) dec = r
+          else continue
+        }
         notes.push({
           key: m.key,
-          calendarId: c.calendarId,
-          dateId: c.dateId,
-          text: c.text || "",
-          author: c.author || v.author,
-          createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
+          calendarId: dec.calendarId || c.calendarId,
+          dateId: dec.dateId || c.dateId,
+          text: dec.text || "",
+          author: dec.author || v.author,
+          createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
         })
       }
       notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
@@ -473,10 +558,15 @@ module.exports = ({ cooler, pmModel }) => {
         sentMarkers.add(`${c.calendarId}::${c.dateId}`)
       }
 
+      const authorByKey = new Map()
+      for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
       const tombstoned = new Set()
       for (const m of messages) {
         const c = (m.value || {}).content
-        if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
+        if (c && c.type === "tombstone" && c.target) {
+          const targetAuthor = authorByKey.get(c.target)
+          if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
+        }
       }
 
       const dueByCalendar = new Map()
@@ -485,9 +575,15 @@ module.exports = ({ cooler, pmModel }) => {
         const v = m.value || {}
         const c = v.content
         if (!c || c.type !== "calendarDate") continue
-        if (new Date(c.date).getTime() > now) continue
+        let dec = c
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
+          if (!r || r._undecryptable) continue
+          dec = r
+        }
+        if (!dec.date || new Date(dec.date).getTime() > now) continue
         if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
-        const entry = { key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" }
+        const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" }
         const list = dueByCalendar.get(c.calendarId) || []
         list.push(entry)
         dueByCalendar.set(c.calendarId, list)

+ 10 - 1
src/models/chats_model.js

@@ -42,21 +42,30 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     const parent = new Map()
     const child = new Map()
     const msgNodes = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
     for (const m of messages) {
       const k = m.key
       const v = m.value || {}
       const c = v.content
       if (!c) continue
-      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
       if (c.type === "chat") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        authorByKey.set(k, v.author)
         if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
       } else if (c.type === "chatMessage") {
         msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        authorByKey.set(k, v.author)
       }
     }
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target)
+      if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
+    }
+
     const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
     const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
 

+ 104 - 39
src/models/maps_model.js

@@ -13,7 +13,7 @@ const normalizeTags = (raw) => {
 
 const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
 
   const openSsb = async () => {
@@ -21,6 +21,12 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
+  const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
+  const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
+  const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
+  const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
+
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
       pull(
@@ -30,8 +36,8 @@ module.exports = ({ cooler }) => {
     });
 
   const getMsg = async (ssbClient, key) =>
-    new Promise((resolve, reject) => {
-      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    new Promise((resolve) => {
+      ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
     });
 
   const buildIndex = (messages) => {
@@ -40,6 +46,9 @@ module.exports = ({ cooler }) => {
     const parent = new Map();
     const child = new Map();
     const markers = new Map();
+    const rawMarkers = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
     for (const m of messages) {
       const k = m.key;
@@ -48,22 +57,20 @@ module.exports = ({ cooler }) => {
       if (!c) continue;
 
       if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
+        tombRequests.push({ target: c.target, author: v.author });
         continue;
       }
 
       if (c.type === "mapMarker") {
         const mapId = c.mapId;
         if (mapId) {
-          if (!markers.has(mapId)) markers.set(mapId, []);
-          markers.get(mapId).push({
+          authorByKey.set(k, v.author);
+          if (!rawMarkers.has(mapId)) rawMarkers.set(mapId, []);
+          rawMarkers.get(mapId).push({
             key: k,
-            lat: parseFloat(c.lat) || 0,
-            lng: parseFloat(c.lng) || 0,
-            label: c.label || "",
-            image: c.image || "",
-            author: v.author || c.author,
-            createdAt: c.createdAt || new Date(v.timestamp || m.timestamp || 0).toISOString()
+            ts: v.timestamp || m.timestamp || 0,
+            c,
+            envAuthor: v.author
           });
         }
         continue;
@@ -73,6 +80,7 @@ module.exports = ({ cooler }) => {
 
       const ts = v.timestamp || m.timestamp || 0;
       nodes.set(k, { key: k, ts, c });
+      authorByKey.set(k, v.author);
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -92,6 +100,11 @@ module.exports = ({ cooler }) => {
       return cur;
     };
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target);
+      if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
+    }
+
     const roots = new Set();
     for (const id of nodes.keys()) roots.add(rootOf(id));
 
@@ -101,24 +114,51 @@ module.exports = ({ cooler }) => {
     const forward = new Map();
     for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
 
-    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward, markers };
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward, markers, rawMarkers };
   };
 
+  const expandMarkers = async (idx) => {
+    for (const [mapId, raws] of idx.rawMarkers.entries()) {
+      const list = [];
+      for (const r of raws) {
+        let c = r.c;
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
+          if (dec && !dec._undecryptable) c = dec;
+        }
+        list.push({
+          key: r.key,
+          lat: parseFloat(c.lat) || 0,
+          lng: parseFloat(c.lng) || 0,
+          label: c.label || "",
+          image: c.image || "",
+          author: c.author || r.envAuthor,
+          encrypted: !!(r.c.encryptedPayload && (!c || c._undecryptable)),
+          createdAt: c.createdAt || new Date(r.ts).toISOString()
+        });
+      }
+      idx.markers.set(mapId, list);
+    }
+  };
+
+
   const buildMap = (node, rootId, viewerId, markerList = []) => {
     const c = node.c || {};
+    const undec = c.encryptedPayload && c._decrypted === false;
     return {
       key: node.key,
       rootId,
-      title: c.title || "",
+      title: undec ? "" : (c.title || ""),
       lat: parseFloat(c.lat) || 0,
       lng: parseFloat(c.lng) || 0,
-      description: c.description || "",
-      markerLabel: c.markerLabel || "",
-      image: c.image || "",
+      description: undec ? "" : (c.description || ""),
+      markerLabel: undec ? "" : (c.markerLabel || ""),
+      image: undec ? "" : (c.image || ""),
       mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
       tags: safeArr(c.tags),
       author: c.author,
       tribeId: c.tribeId || null,
+      encrypted: !!undec,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       markers: markerList.filter((mk) => !mk.tombstoned)
@@ -159,7 +199,7 @@ module.exports = ({ cooler }) => {
       const now = new Date().toISOString();
       const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
 
-      const content = {
+      let content = {
         type: "map",
         title: title || "",
         lat: parseFloat(lat) || 0,
@@ -175,6 +215,8 @@ module.exports = ({ cooler }) => {
         updatedAt: now
       };
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
@@ -187,32 +229,39 @@ module.exports = ({ cooler }) => {
       const oldMsg = await getMsg(ssbClient, tipId);
 
       if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found");
-      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+      const oldDecrypted = await decryptIfTribe(oldMsg.content);
+      assertReadable(oldDecrypted, "Map");
+      if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
 
-      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDecrypted.tags);
       const now = new Date().toISOString();
-      const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldMsg.content.mapType;
+      const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldDecrypted.mapType;
 
-      const updated = {
-        ...oldMsg.content,
+      let updated = {
+        type: "map",
         replaces: tipId,
-        title: title !== undefined ? title || "" : oldMsg.content.title || "",
-        lat: lat !== undefined ? parseFloat(lat) || 0 : oldMsg.content.lat,
-        lng: lng !== undefined ? parseFloat(lng) || 0 : oldMsg.content.lng,
-        description: description !== undefined ? description || "" : oldMsg.content.description || "",
+        title: title !== undefined ? title || "" : oldDecrypted.title || "",
+        lat: lat !== undefined ? parseFloat(lat) || 0 : oldDecrypted.lat,
+        lng: lng !== undefined ? parseFloat(lng) || 0 : oldDecrypted.lng,
+        description: description !== undefined ? description || "" : oldDecrypted.description || "",
+        markerLabel: oldDecrypted.markerLabel || "",
         mapType: mType,
         tags,
-        ...(image ? { image } : {}),
-        createdAt: oldMsg.content.createdAt,
+        author: oldDecrypted.author || userId,
+        ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
+        ...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
+        createdAt: oldDecrypted.createdAt,
         updatedAt: now
       };
 
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
-      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      updated = await encryptIfTribe(updated);
 
-      return new Promise((resolve, reject) => {
-        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      const result = await new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      return result;
     },
 
     async deleteMapById(id) {
@@ -222,7 +271,8 @@ module.exports = ({ cooler }) => {
       const msg = await getMsg(ssbClient, tipId);
 
       if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
-      if (msg.content.author !== userId) throw new Error("Not the author");
+      const decrypted = await decryptIfTribe(msg.content);
+      if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author");
 
       const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
 
@@ -245,22 +295,28 @@ module.exports = ({ cooler }) => {
       const node = idx.nodes.get(tipId);
       if (!node) throw new Error("Map not found");
 
-      const mapType = node.c.mapType || "SINGLE";
+      const mapDecrypted = await decryptIfTribe(node.c);
+      assertReadable(mapDecrypted, "Map");
+      const mapType = mapDecrypted.mapType || node.c.mapType || "SINGLE";
+      const mapAuthor = mapDecrypted.author || node.c.author;
       if (mapType === "SINGLE") throw new Error("Map does not allow markers");
-      if (mapType === "CLOSED" && node.c.author !== userId) throw new Error("Only the map creator can add markers");
+      if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers");
 
       const now = new Date().toISOString();
-      const content = {
+      let content = {
         type: "mapMarker",
         mapId: tipId,
         lat: parseFloat(lat) || 0,
         lng: parseFloat(lng) || 0,
         label: label || "",
         author: userId,
-        createdAt: now
+        createdAt: now,
+        ...(node.c.tribeId ? { tribeId: node.c.tribeId } : {})
       };
       if (image) content.image = image;
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
@@ -276,6 +332,8 @@ module.exports = ({ cooler }) => {
 
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
+      await expandMarkers(idx);
 
       const items = [];
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
@@ -312,6 +370,8 @@ module.exports = ({ cooler }) => {
 
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
+      await expandMarkers(idx);
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -324,8 +384,13 @@ module.exports = ({ cooler }) => {
       if (!node) {
         const msg = await getMsg(ssbClient, tip);
         if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
+        let c = msg.content;
+        if (c.encryptedPayload && tribeCrypto && tribesModel) {
+          const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
+          c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
+        }
         const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
-        return buildMap({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer, markerList);
+        return buildMap({ key: tip, ts: msg.timestamp || 0, c }, root, viewer, markerList);
       }
 
       const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));

+ 9 - 1
src/models/pads_model.js

@@ -106,19 +106,27 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
     const nodes = new Map()
     const parent = new Map()
     const child = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
     for (const m of messages) {
       const k = m.key
       const v = m.value || {}
       const c = v.content
       if (!c) continue
-      if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
+      if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
       if (c.type === "pad") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
+        authorByKey.set(k, v.author)
         if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
       }
     }
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target)
+      if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
+    }
+
     const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
     const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
 

+ 100 - 6
src/models/parliament_model.js

@@ -444,7 +444,10 @@ module.exports = ({ cooler, services = {} }) => {
     const isTribe = term.powerType === 'tribe';
     let members = 1;
     if (isTribe && term.powerId) {
-      const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
+      let tribe = null;
+      if (services.tribes) {
+        try { tribe = await services.tribes.getTribeById(term.powerId); } catch {}
+      }
       members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
     }
     const pol = await summarizePoliciesForTerm({ ...term });
@@ -1067,7 +1070,7 @@ module.exports = ({ cooler, services = {} }) => {
     if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
 
     if (latestAny && isExpiredTerm(latestAny)) {
-      try { await enactApprovedChanges(latestAny); } catch {}
+      try { await enactApprovedChanges(latestAny); } catch (e) { console.error('enactApprovedChanges failed:', e); }
     }
 
     const open = await listCandidaturesOpen();
@@ -1183,7 +1186,10 @@ module.exports = ({ cooler, services = {} }) => {
     if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
     if (term.powerType === 'inhabitant') return term.powerId === userId;
     if (term.powerType === 'tribe') {
-      const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
+      let tribe = null;
+      if (services.tribes) {
+        try { tribe = await services.tribes.getTribeById(term.powerId); } catch {}
+      }
       const members = ensureArray(tribe?.members);
       return members.includes(userId);
     }
@@ -1201,6 +1207,13 @@ module.exports = ({ cooler, services = {} }) => {
   };
 
   const tribeListByType = async (type, tribeId) => {
+    let chainIds;
+    try {
+      chainIds = services.tribes && services.tribes.getChainIds
+        ? await services.tribes.getChainIds(tribeId)
+        : [tribeId];
+    } catch (_) { chainIds = [tribeId]; }
+    const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
     const msgs = await tribeReadLog();
     const tomb = new Set();
     const replaced = new Set();
@@ -1209,7 +1222,7 @@ module.exports = ({ cooler, services = {} }) => {
       const c = m.value?.content; if (!c) continue;
       if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
       if (c.type !== type) continue;
-      if (c.tribeId !== tribeId) continue;
+      if (!tribeIdSet.has(c.tribeId)) continue;
       if (c.replaces) replaced.add(c.replaces);
       items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 });
     }
@@ -1226,6 +1239,77 @@ module.exports = ({ cooler, services = {} }) => {
     return terms[0] || null;
   };
 
+  const tribeElectionInFlight = new Map();
+
+  async function tribePublishInitialTerm(tribeId) {
+    if (!tribeId) throw new Error('Missing tribeId');
+    await openSsb();
+    const existing = await tribeListByType('tribeParliamentTerm', tribeId);
+    if (existing.length > 0) return existing[0];
+    const startAt = moment().toISOString();
+    const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
+    const term = {
+      type: 'tribeParliamentTerm',
+      tribeId,
+      method: 'ANARCHY',
+      leaderId: null,
+      winnerVotes: 0,
+      totalVotes: 0,
+      startAt,
+      endAt,
+      createdBy: userId,
+      createdAt: nowISO()
+    };
+    return await publishMsg(term);
+  }
+
+  async function tribeResolveElectionImpl(tribeId) {
+    const latest = await tribeGetCurrentTerm(tribeId);
+    if (latest && !isExpiredTerm(latest)) return latest;
+    const opens = (await tribeListCandidatures(tribeId)).filter(c => (c.status || 'OPEN') === 'OPEN');
+    let chosen = null, totalVotes = 0, winnerVotes = 0;
+    if (opens.length) {
+      opens.sort((a, b) => Number(b.votes || 0) - Number(a.votes || 0) || new Date(a.createdAt) - new Date(b.createdAt));
+      chosen = opens[0];
+      totalVotes = opens.reduce((s, c) => s + Number(c.votes || 0), 0);
+      winnerVotes = Number(chosen.votes || 0);
+      if (winnerVotes <= 0) chosen = null;
+    }
+    const startAt = moment().toISOString();
+    const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
+    const term = {
+      type: 'tribeParliamentTerm',
+      tribeId,
+      method: chosen ? String(chosen.method || 'DEMOCRACY').toUpperCase() : 'ANARCHY',
+      leaderId: chosen ? chosen.candidateId : null,
+      winnerVotes,
+      totalVotes,
+      startAt,
+      endAt,
+      createdBy: userId,
+      createdAt: nowISO()
+    };
+    const res = await publishMsg(term);
+    for (const c of opens) {
+      try { await publishMsg({ type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId }); } catch {}
+    }
+    return res;
+  }
+
+  async function tribeResolveElection(tribeId) {
+    if (tribeElectionInFlight.has(tribeId)) return tribeElectionInFlight.get(tribeId);
+    const p = tribeResolveElectionImpl(tribeId).catch(e => { console.error('tribeResolveElection failed:', e); return null; }).finally(() => tribeElectionInFlight.delete(tribeId));
+    tribeElectionInFlight.set(tribeId, p);
+    return p;
+  }
+
+  async function tribeEnsureTerm(tribeId) {
+    const cur = await tribeGetCurrentTerm(tribeId);
+    if (cur && !isExpiredTerm(cur)) return cur;
+    if (!cur) return await tribePublishInitialTerm(tribeId);
+    return await tribeResolveElection(tribeId);
+  }
+
   const tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId);
   const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
 
@@ -1285,11 +1369,18 @@ module.exports = ({ cooler, services = {} }) => {
   };
 
   const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => {
+    let chainIds;
+    try {
+      chainIds = services.tribes && services.tribes.getChainIds
+        ? await services.tribes.getChainIds(tribeId)
+        : [tribeId];
+    } catch (_) { chainIds = [tribeId]; }
+    const tribeIdSet = new Set(Array.isArray(chainIds) && chainIds.length ? chainIds : [tribeId]);
     const msgs = await tribeReadLog();
     const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
     return msgs.some(m => {
       const c = m.value?.content; if (!c) return false;
-      return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && c.targetId === tribeId && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
+      return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && tribeIdSet.has(c.targetId) && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
     });
   };
 
@@ -1326,7 +1417,10 @@ module.exports = ({ cooler, services = {} }) => {
       voteTribeCandidature: tribeVoteCandidature,
       publishTribeRule: tribePublishRule,
       deleteTribeRule: tribeDeleteRule,
-      hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle
+      hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle,
+      publishInitialTerm: tribePublishInitialTerm,
+      resolveElection: tribeResolveElection,
+      ensureTerm: tribeEnsureTerm
     }
   };
 };

+ 85 - 35
src/models/torrents_model.js

@@ -23,7 +23,7 @@ const parseBlobId = (blobMarkdown) => {
 const voteSum = (opinions = {}) =>
   Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
 
   const openSsb = async () => {
@@ -31,14 +31,20 @@ module.exports = ({ cooler }) => {
     return ssb;
   };
 
+  const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
+  const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
+  const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
+  const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
+  const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
+
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
     });
 
   const getMsg = async (ssbClient, key) =>
-    new Promise((resolve, reject) => {
-      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    new Promise((resolve) => {
+      ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
     });
 
   const buildIndex = (messages) => {
@@ -46,6 +52,8 @@ module.exports = ({ cooler }) => {
     const nodes = new Map();
     const parent = new Map();
     const child = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
     for (const m of messages) {
       const k = m.key;
@@ -54,7 +62,7 @@ module.exports = ({ cooler }) => {
       if (!c) continue;
 
       if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
+        tombRequests.push({ target: c.target, author: v.author });
         continue;
       }
 
@@ -62,6 +70,7 @@ module.exports = ({ cooler }) => {
 
       const ts = v.timestamp || m.timestamp || 0;
       nodes.set(k, { key: k, ts, c });
+      authorByKey.set(k, v.author);
 
       if (c.replaces) {
         parent.set(k, c.replaces);
@@ -81,6 +90,11 @@ module.exports = ({ cooler }) => {
       return cur;
     };
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target);
+      if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
+    }
+
     const roots = new Set();
     for (const id of nodes.keys()) roots.add(rootOf(id));
 
@@ -95,24 +109,28 @@ module.exports = ({ cooler }) => {
 
   const buildTorrent = (node, rootId, viewerId) => {
     const c = node.c || {};
+    const undec = c.encryptedPayload && c._decrypted === false;
     const voters = safeArr(c.opinions_inhabitants);
     return {
       key: node.key,
       rootId,
-      url: c.url,
+      url: undec ? "" : c.url,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       tags: safeArr(c.tags),
       author: c.author,
-      title: c.title || "",
-      description: c.description || "",
+      title: undec ? "" : (c.title || ""),
+      description: undec ? "" : (c.description || ""),
       size: c.size || 0,
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      tribeId: c.tribeId || null,
+      encrypted: !!undec
     };
   };
 
+
   return {
     type: "torrent",
 
@@ -141,13 +159,13 @@ module.exports = ({ cooler }) => {
       return root;
     },
 
-    async createTorrent(blobMarkdown, tagsRaw, title, description, size) {
+    async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) {
       const ssbClient = await openSsb();
       const blobId = parseBlobId(blobMarkdown);
       const tags = normalizeTags(tagsRaw) || [];
       const now = new Date().toISOString();
 
-      const content = {
+      let content = {
         type: "torrent",
         url: blobId,
         createdAt: now,
@@ -158,9 +176,12 @@ module.exports = ({ cooler }) => {
         description: description || "",
         size: Number(size) || 0,
         opinions: {},
-        opinions_inhabitants: []
+        opinions_inhabitants: [],
+        ...(tribeId ? { tribeId } : {})
       };
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
@@ -173,30 +194,39 @@ module.exports = ({ cooler }) => {
       const oldMsg = await getMsg(ssbClient, tipId);
 
       if (!oldMsg || oldMsg.content?.type !== "torrent") throw new Error("Torrent not found");
-      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
-      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+      const oldDec = await decryptIfTribe(oldMsg.content);
+      assertReadable(oldDec, "Torrent");
+      if (Object.keys(oldDec.opinions || oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
+      if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
 
-      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
       const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
       const now = new Date().toISOString();
 
-      const updated = {
-        ...oldMsg.content,
+      let updated = {
+        type: "torrent",
         replaces: tipId,
-        url: blobId || oldMsg.content.url,
+        url: blobId || oldDec.url,
         tags,
-        title: title !== undefined ? title || "" : oldMsg.content.title || "",
-        description: description !== undefined ? description || "" : oldMsg.content.description || "",
-        createdAt: oldMsg.content.createdAt,
+        title: title !== undefined ? title || "" : oldDec.title || "",
+        description: description !== undefined ? description || "" : oldDec.description || "",
+        size: oldDec.size || 0,
+        opinions: oldDec.opinions || {},
+        opinions_inhabitants: oldDec.opinions_inhabitants || [],
+        author: oldDec.author || userId,
+        ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
+        createdAt: oldDec.createdAt,
         updatedAt: now
       };
 
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
-      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      updated = await encryptIfTribe(updated);
 
-      return new Promise((resolve, reject) => {
-        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      const result = await new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      return result;
     },
 
     async deleteTorrentById(id) {
@@ -206,7 +236,8 @@ module.exports = ({ cooler }) => {
       const msg = await getMsg(ssbClient, tipId);
 
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
-      if (msg.content.author !== userId) throw new Error("Not the author");
+      const dec = await decryptIfTribe(msg.content);
+      if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author");
 
       const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
 
@@ -226,6 +257,7 @@ module.exports = ({ cooler }) => {
 
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
 
       const items = [];
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
@@ -270,6 +302,7 @@ module.exports = ({ cooler }) => {
       const viewer = viewerId || ssbClient.id;
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
 
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -283,7 +316,12 @@ module.exports = ({ cooler }) => {
 
       const msg = await getMsg(ssbClient, tip);
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
-      return buildTorrent({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
+      let c = msg.content;
+      if (c.encryptedPayload && tribeCrypto && tribesModel) {
+        const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
+        c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
+      }
+      return buildTorrent({ key: tip, ts: msg.timestamp || 0, c }, root, viewer);
     },
 
     async createOpinion(id, category) {
@@ -297,27 +335,39 @@ module.exports = ({ cooler }) => {
 
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
 
-      const voters = safeArr(msg.content.opinions_inhabitants);
+      const oldDec = await decryptIfTribe(msg.content);
+      assertReadable(oldDec, "Torrent");
+      const voters = safeArr(oldDec.opinions_inhabitants || msg.content.opinions_inhabitants);
       if (voters.includes(userId)) throw new Error("Already voted");
 
       const now = new Date().toISOString();
-      const updated = {
-        ...msg.content,
+      let updated = {
+        type: "torrent",
         replaces: tipId,
+        url: oldDec.url,
+        tags: oldDec.tags || [],
+        title: oldDec.title || "",
+        description: oldDec.description || "",
+        size: oldDec.size || 0,
         opinions: {
-          ...msg.content.opinions,
-          [category]: (msg.content.opinions?.[category] || 0) + 1
+          ...(oldDec.opinions || {}),
+          [category]: ((oldDec.opinions || {})[category] || 0) + 1
         },
         opinions_inhabitants: voters.concat(userId),
+        author: oldDec.author,
+        ...(msg.content.tribeId ? { tribeId: msg.content.tribeId } : {}),
+        createdAt: oldDec.createdAt,
         updatedAt: now
       };
 
-      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
-      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      updated = await encryptIfTribe(updated);
 
-      return new Promise((resolve, reject) => {
-        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      const result = await new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
       });
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+      return result;
     }
   };
 };

+ 109 - 8
src/models/tribe_crypto.js

@@ -6,9 +6,16 @@ const SENSITIVE_FIELDS = [
   'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
   'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
   'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
-  'opinions_inhabitants', 'parentId', 'status', 'priority', 'date', 'mediaType'
+  'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType'
 ];
 
+const ENVELOPE_PRESERVE = new Set([
+  'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
+  'createdAt', 'updatedAt', 'encryptedPayload',
+  'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
+  '_decrypted', '_undecryptable'
+]);
+
 const INVITE_SALT = 'SolarNET.HuB';
 
 module.exports = (configPath) => {
@@ -26,7 +33,9 @@ module.exports = (configPath) => {
   };
 
   const saveKeyring = () => {
-    fs.writeFileSync(keyringPath, JSON.stringify(keyring, null, 2), 'utf8');
+    const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
+    fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), 'utf8');
+    fs.renameSync(tmp, keyringPath);
   };
 
   const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
@@ -89,6 +98,23 @@ module.exports = (configPath) => {
     return decryptWithKey(encryptedKey, derived.toString('hex'));
   };
 
+  const encryptChainForInvite = (ancestryRootIds, inviteCode) => {
+    const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) }));
+    if (chain.some(e => !e.key)) return null;
+    const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
+    return encryptWithKey(JSON.stringify(chain), derived.toString('hex'));
+  };
+
+  const decryptChainFromInvite = (encryptedPayload, inviteCode) => {
+    const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
+    try {
+      const json = decryptWithKey(encryptedPayload, derived.toString('hex'));
+      const parsed = JSON.parse(json);
+      if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && e.key)) return parsed;
+    } catch (_) {}
+    return null;
+  };
+
   const encryptChain = (plaintext, keyChain) => {
     let data = plaintext;
     for (const keyHex of keyChain) {
@@ -106,18 +132,25 @@ module.exports = (configPath) => {
     return data;
   };
 
-  const encryptContent = (content, keyChain) => {
+  const encryptContent = (content, keyChain, customFields) => {
     const payload = {};
-    for (const field of SENSITIVE_FIELDS) {
-      if (content[field] !== undefined) {
-        payload[field] = content[field];
+    if (customFields) {
+      for (const [k, v] of Object.entries(content)) {
+        if (ENVELOPE_PRESERVE.has(k)) continue;
+        payload[k] = v;
+      }
+    } else {
+      for (const field of SENSITIVE_FIELDS) {
+        if (content[field] !== undefined) payload[field] = content[field];
       }
     }
     const plaintext = JSON.stringify(payload);
     const encryptedPayload = encryptChain(plaintext, keyChain);
     const result = {};
     for (const [k, v] of Object.entries(content)) {
-      if (!SENSITIVE_FIELDS.includes(k)) result[k] = v;
+      if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
+        result[k] = v;
+      }
     }
     result.encryptedPayload = encryptedPayload;
     return result;
@@ -137,7 +170,7 @@ module.exports = (configPath) => {
         continue;
       }
     }
-    return { ...content, encrypted: true };
+    return { ...content, _undecryptable: true };
   };
 
   const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => {
@@ -165,17 +198,85 @@ module.exports = (configPath) => {
     return sets;
   };
 
+  const resolveKeyChain = async (tribeId, tribesModel) => {
+    if (!tribeId || !tribesModel) return null;
+    let ancestryIds;
+    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
+    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
+    const chain = [];
+    for (const rootId of ancestryIds) {
+      const key = getKey(rootId);
+      if (!key) return null;
+      chain.push(key);
+    }
+    return chain.length ? chain : null;
+  };
+
+  const resolveKeyChainSets = async (tribeId, tribesModel) => {
+    if (!tribeId || !tribesModel) return null;
+    let ancestryIds;
+    try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
+    if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
+    return buildKeyChainSets(ancestryIds);
+  };
+
+  const encryptForTribe = async (content, tribeId, tribesModel) => {
+    const chain = await resolveKeyChain(tribeId, tribesModel);
+    if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
+    return encryptContent(content, chain, true);
+  };
+
+  const decryptFromTribe = async (content, tribesModel) => {
+    if (!content || !content.encryptedPayload) return content;
+    const tid = content.tribeId;
+    if (!tid) return content;
+    const sets = await resolveKeyChainSets(tid, tribesModel);
+    if (!sets || !sets.length) return { ...content, _undecryptable: true };
+    return decryptContent(content, sets);
+  };
+
+  const createHelpers = (tribesModel) => ({
+    async encryptIfTribe(content) {
+      if (!content.tribeId || !tribesModel) return content;
+      return await encryptForTribe(content, content.tribeId, tribesModel);
+    },
+    async decryptIfTribe(content) {
+      if (!content || !content.encryptedPayload || !tribesModel) return content;
+      return await decryptFromTribe(content, tribesModel);
+    },
+    assertReadable(decrypted, what) {
+      if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
+    },
+    async decryptIndexNodes(idx) {
+      if (!tribesModel) return;
+      for (const [k, n] of idx.nodes.entries()) {
+        if (!n.c || !n.c.encryptedPayload) continue;
+        const dec = await decryptFromTribe(n.c, tribesModel);
+        if (dec && !dec._undecryptable) {
+          idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
+        } else {
+          idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
+        }
+      }
+    }
+  });
+
   loadKeyring();
 
   return {
     SENSITIVE_FIELDS,
+    ENVELOPE_PRESERVE,
     loadKeyring, saveKeyring,
     generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
     encryptWithKey, decryptWithKey,
     encryptForInvite, decryptFromInvite,
+    encryptChainForInvite, decryptChainFromInvite,
     encryptChain, decryptChain,
     encryptContent, decryptContent,
     boxKeyForMember, unboxKeyFromMember,
     buildKeyChainSets,
+    resolveKeyChain, resolveKeyChainSets,
+    encryptForTribe, decryptFromTribe,
+    createHelpers,
   };
 };

+ 26 - 25
src/models/tribes_content_model.js

@@ -13,23 +13,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
 
   const TYPE = 'tribe-content';
 
-  const resolveKeyChain = async (tribeId) => {
-    if (!tribeCrypto || !tribesModel) return null;
-    const ancestryIds = await tribesModel.getAncestryChain(tribeId);
-    const chain = [];
-    for (const rootId of ancestryIds) {
-      const key = tribeCrypto.getKey(rootId);
-      if (!key) return null;
-      chain.push(key);
-    }
-    return chain;
-  };
+  const resolveKeyChain = async (tribeId) =>
+    (tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChain(tribeId, tribesModel) : null;
 
-  const resolveKeyChainSets = async (tribeId) => {
-    if (!tribeCrypto || !tribesModel) return null;
-    const ancestryIds = await tribesModel.getAncestryChain(tribeId);
-    return tribeCrypto.buildKeyChainSets(ancestryIds);
-  };
+  const resolveKeyChainSets = async (tribeId) =>
+    (tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChainSets(tribeId, tribesModel) : null;
 
   const publish = async (content) => {
     const ssbClient = await openSsb();
@@ -52,18 +40,26 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     const tombstoned = new Set();
     const replaced = new Map();
     const items = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
     for (const m of msgs) {
       const c = m.value?.content;
       if (!c) continue;
-      if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+      if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
       if (c.type !== TYPE) continue;
+      authorByKey.set(m.key, m.value?.author);
       if (tribeId && c.tribeId !== tribeId) continue;
       if (contentType && c.contentType !== contentType) continue;
       if (c.replaces) replaced.set(c.replaces, m.key);
       items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
     }
 
+    for (const t of tombRequests) {
+      const targetAuthor = authorByKey.get(t.target);
+      if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
+    }
+
     for (const id of tombstoned) items.delete(id);
     for (const oldId of replaced.keys()) items.delete(oldId);
 
@@ -74,9 +70,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         if (!result[i].encryptedPayload) continue;
         const tid = result[i].tribeId;
         if (!keyChainCache.has(tid)) {
-          keyChainCache.set(tid, tribeCrypto.buildKeyChainSets(
-            await tribesModel.getAncestryChain(tid)
-          ));
+          const sets = await tribeCrypto.resolveKeyChainSets(tid, tribesModel);
+          keyChainCache.set(tid, sets || []);
         }
         result[i] = tribeCrypto.decryptContent(result[i], keyChainCache.get(tid));
       }
@@ -145,6 +140,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async update(contentId, data, existing) {
       if (!existing) existing = await this.getById(contentId);
       if (!existing) throw new Error('Content not found');
+      if (existing._undecryptable) throw new Error('Content is tribe-encrypted and cannot be decrypted with available keys');
       if (data.status && !VALID_STATUSES.includes(data.status)) {
         throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
       }
@@ -208,25 +204,30 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       const tombstoned = new Set();
       const replaced = new Map();
       const items = new Map();
+      const authorByKey = new Map();
+      const tombRequests = [];
 
       for (const m of msgs) {
         const c = m.value?.content;
         if (!c) continue;
-        if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+        if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
         if (c.type !== TYPE) continue;
+        authorByKey.set(m.key, m.value?.author);
         if (c.replaces) replaced.set(c.replaces, m.key);
         items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
       }
+      for (const t of tombRequests) {
+        const targetAuthor = authorByKey.get(t.target);
+        if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
+      }
 
       let latestId = contentId;
       while (replaced.has(latestId)) latestId = replaced.get(latestId);
       if (tombstoned.has(latestId)) return null;
       const item = items.get(latestId) || null;
       if (!item || !item.encryptedPayload || !tribeCrypto || !tribesModel) return item;
-      const keyChainSets = tribeCrypto.buildKeyChainSets(
-        await tribesModel.getAncestryChain(item.tribeId)
-      );
-      return tribeCrypto.decryptContent(item, keyChainSets);
+      const keyChainSets = await tribeCrypto.resolveKeyChainSets(item.tribeId, tribesModel);
+      return tribeCrypto.decryptContent(item, keyChainSets || []);
     },
 
     async listByTribe(tribeId, contentType, filter) {

+ 198 - 22
src/models/tribes_model.js

@@ -13,6 +13,45 @@ module.exports = ({ cooler, tribeCrypto }) => {
   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();
@@ -21,23 +60,68 @@ module.exports = ({ cooler, tribeCrypto }) => {
         client.createLogStream({ limit: logLimit }),
         pull.collect((err, msgs) => {
           if (err) return reject(err);
-          const tombstoned = new Set();
-          const parent = new Map();
-          const child = new Map();
-          const tribes = new Map();
+          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;
-            if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); 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;
-            if (c.replaces) {
-              parent.set(k, c.replaces);
-              child.set(c.replaces, k);
+            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;
+            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;
+              }
+              parent.set(k, replaces);
+              child.set(replaces, k);
+              tribes.set(k, entry);
+              rootByTip.set(k, root);
+              progress = true;
             }
-            tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
           }
-          const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
+          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()) {
@@ -45,7 +129,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
             const tip = tipOf(root);
             tipByRoot.set(root, tip);
           }
-          tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
+          tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip };
           tribeIndexTs = Date.now();
           resolve(tribeIndex);
         })
@@ -110,11 +194,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
       let invite = code;
       if (tribeCrypto) {
-        const rootId = await this.getRootId(tribeId);
-        const tribeKey = tribeCrypto.getKey(rootId);
-        if (tribeKey) {
-          const ek = tribeCrypto.encryptForInvite(tribeKey, code);
-          invite = { code, ek, gen: tribeCrypto.getGen(rootId) };
+        const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
+        if (Array.isArray(ancestryIds) && ancestryIds.length) {
+          const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code);
+          if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) };
         }
       }
       const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
@@ -164,10 +247,24 @@ module.exports = ({ cooler, tribeCrypto }) => {
       if (matchedTribe.members.includes(userId)) {
         throw new Error('Already a member of this tribe');
       }
-      if (tribeCrypto && typeof matchedInvite === 'object' && matchedInvite.ek) {
-        const tribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
-        const rootId = await this.getRootId(matchedTribe.id);
-        tribeCrypto.setKey(rootId, tribeKey, matchedInvite.gen || 1);
+      let storedTribeKey = null;
+      let storedGen = 1;
+      let storedRootId = null;
+      if (tribeCrypto && typeof matchedInvite === 'object') {
+        if (matchedInvite.ekChain) {
+          const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
+          if (Array.isArray(chain) && chain.length) {
+            for (const entry of chain) 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);
+          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 => {
@@ -175,6 +272,20 @@ module.exports = ({ cooler, tribeCrypto }) => {
         return 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;
     },
 
@@ -211,6 +322,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       await new Promise((resolve, reject) => {
         ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res));
       });
+      await this.ensureFollowTribeMembers(tribeId).catch(() => {});
     },
 
     async ensureTribeKeyDistribution(tribeId) {
@@ -297,7 +409,12 @@ module.exports = ({ cooler, tribeCrypto }) => {
     },
 
     async listAll() {
-      const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
+      const { tribes, tombstoned, 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;
@@ -317,7 +434,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
           invites: Array.isArray(c.invites) ? c.invites : [],
           inviteMode: c.inviteMode || 'strict',
           status: c.status || 'OPEN',
-          parentTribeId: c.parentTribeId || null,
+          parentTribeId: resolveParent(c.parentTribeId),
           mapUrl: c.mapUrl || "",
           createdAt: c.createdAt,
           updatedAt: c.updatedAt,
@@ -386,8 +503,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
       }
       const tribe = await this.getTribeById(tribeId);
       if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
+        const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]);
         const updatedInvites = tribe.invites.map(inv => {
           if (typeof inv === 'object' && inv.code) {
+            const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code);
+            if (ekChain) return { code: inv.code, ekChain, gen: newGen };
             return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
           }
           return inv;
@@ -420,6 +540,62 @@ module.exports = ({ cooler, tribeCrypto }) => {
         }
       }
     },
+
+    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: logLimit }),
+          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();

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.4",
+  "version": "0.7.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.4",
+  "version": "0.7.5",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",

+ 0 - 1
src/server/ssb_config.js

@@ -14,7 +14,6 @@ const cliArgs = ~i ? argv.slice(0, i) : argv;
 let config = Config('ssb', minimist(conf));
 config = { ...config, ...configData };
 
-// Set blob size limit to 50MB
 const megabyte = Math.pow(2, 20);
 config.blobs = config.blobs || {};
 config.blobs.max = 50 * megabyte;

+ 8 - 2
src/views/blockchain_view.js

@@ -83,6 +83,7 @@ const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
   );
 
 const getViewDetailsAction = (type, block) => {
+  if (block && block.content && typeof block.content.encryptedPayload === 'string') return null;
   switch (type) {
     case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
     case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
@@ -132,6 +133,10 @@ const getViewDetailsAction = (type, block) => {
     case 'chat': return `/chats/${encodeURIComponent(block.id)}`;
     case 'gameScore': return `/games?filter=scoring`;
     case 'log': return `/logs/view/${encodeURIComponent(block.id)}`;
+    case 'calendarDate':
+    case 'calendarNote': return block.content?.calendarId ? `/calendars/${encodeURIComponent(block.content.calendarId)}` : `/calendars`;
+    case 'padEntry': return block.content?.padId ? `/pads/${encodeURIComponent(block.content.padId)}` : `/pads`;
+    case 'chatMessage': return block.content?.roomId ? `/chats/${encodeURIComponent(block.content.roomId)}` : `/chats`;
     default: return null;
   }
 };
@@ -173,9 +178,10 @@ const renderBlockDiagram = (blocks, qs) => {
       ].filter(Boolean).join(' | ') || '—';
 
       const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
+      const typeClass = `bd-type-${String(block.type || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
       return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' },
-        div({ class: 'block-diagram', style: `border-color:${color};` },
-          div({ class: 'block-diagram-ruler', style: `border-bottom-color:${color};` },
+        div({ class: `block-diagram ${typeClass}` },
+          div({ class: 'block-diagram-ruler' },
             span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
           ),
           div({ class: 'block-diagram-grid' },

+ 2 - 2
src/views/chats_view.js

@@ -213,7 +213,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
   const q = safeText(params.q || "")
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
   const isAuthor = String(chat.author) === String(userId)
-  const isMember = safeArr(chat.members).includes(userId)
+  const isMember = safeArr(chat.members).includes(userId) || (!!chat.tribeId && !!chat.isTribeMember)
   const fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
   const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
 
@@ -250,7 +250,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
       ) : null
     ),
     isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
-      isAuthor
+      isAuthor && chat.status === "INVITE-ONLY"
         ? form({ method: "POST", action: `/chats/generate-invite` },
             input({ type: "hidden", name: "chatId", value: chat.key }),
             input({ type: "hidden", name: "returnTo", value: returnTo }),

+ 3 - 3
src/views/pads_view.js

@@ -202,7 +202,7 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
 
 exports.singlePadView = async (pad, entries, params) => {
   const isAuthor = String(pad.author) === String(userId)
-  const isMember = pad.members.includes(userId)
+  const isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember)
   const padClosed = pad.isClosed
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
   const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
@@ -231,7 +231,7 @@ exports.singlePadView = async (pad, entries, params) => {
       isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
     ),
     isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
-      isAuthor
+      isAuthor && pad.status === "INVITE-ONLY"
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
           )
@@ -271,7 +271,7 @@ exports.singlePadView = async (pad, entries, params) => {
           )
         )
       : null,
-    !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
+    !isRestrictedInviteOnly && !isAuthor && !isMember && pad.status === "OPEN" && !padClosed
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
           button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
         )

+ 27 - 27
src/views/stats_view.js

@@ -95,7 +95,7 @@ exports.statsView = (stats, filter) => {
         h2(title),
         p(description)
       ),
-      div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
+      div({ class: 'mode-buttons stats-grid' },
         modes.map(m =>
           form({ method: 'GET', action: '/stats' },
             input({ type: 'hidden', name: 'filter', value: m }),
@@ -105,15 +105,15 @@ exports.statsView = (stats, filter) => {
       ),
       section(
         div({ style: headerStyle },
-          h3({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsCreatedAt}: `, span({ style: 'color:#888;' }, stats.createdAt)),
-          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' },
-            a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, style: 'color:#007bff; text-decoration:none;' }, stats.id)
+          h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)),
+          h3({ class: 'stats-section-h' },
+            a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id)
           ),
-          div({ style: 'margin-bottom:16px;' },
-            ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlobsSize}: `, span({ style: 'color:#888;' }, stats.statsBlobsSize)),
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsBlockchainSize}: `, span({ style: 'color:#888;' }, stats.statsBlockchainSize)),
-              li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, strong(`${i18n.statsSize}: `, span({ style: 'color:#888;' }, span({ style: 'color:#555;' }, stats.folderSize))))
+          div({ class: 'stats-mb-16' },
+            ul({ class: 'stats-list-reset' },
+              li({ class: 'stats-h-row' }, `${i18n.statsBlobsSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlobsSize)),
+              li({ class: 'stats-h-row' }, `${i18n.statsBlockchainSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlockchainSize)),
+              li({ class: 'stats-h-row' }, strong(`${i18n.statsSize}: `, span({ class: 'stats-muted-888' }, span({ class: 'stats-muted-555' }, stats.folderSize))))
             )
           )
         ),
@@ -157,7 +157,7 @@ exports.statsView = (stats, filter) => {
                   span(`${networkCO2} g CO₂`)
                 ),
                 div({ class: 'carbon-bar-track' },
-                  div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
+                  div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
                 ),
                 p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
                 p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
@@ -182,7 +182,7 @@ exports.statsView = (stats, filter) => {
                   span(`${networkCO2} g CO₂`)
                 ),
                 div({ class: 'carbon-bar-track' },
-                  div({ class: 'carbon-bar-fill carbon-bar-network', style: 'width:100%;' })
+                  div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
                 ),
                 p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
                 p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
@@ -202,7 +202,7 @@ exports.statsView = (stats, filter) => {
                 span(`${maxAnnualCO2} g CO₂`)
               ),
               div({ class: 'carbon-bar-track' },
-                div({ class: 'carbon-bar-fill carbon-bar-max', style: 'width:100%;' })
+                div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
               ),
               p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
               p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
@@ -210,22 +210,22 @@ exports.statsView = (stats, filter) => {
           })()
         ),
         div({ style: headerStyle },
-          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
-          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', style: 'color:#007bff; text-decoration:none; word-break:break-all;' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsTotalEcoAddresses}: `, span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0)))
+          h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
+          ul({ class: 'stats-list-reset' },
+            li({ class: 'stats-h-row' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
+            li({ class: 'stats-h-row' }, `${i18n.statsTotalEcoAddresses}: `, span({ class: 'stats-muted-888' }, String(stats?.banking?.totalAddresses || 0)))
           )
         ),
         div({ style: headerStyle },
-          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsLogsTitle || 'Logs'),
-          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ style: 'color:#888;' }, String(stats?.logsCount || 0)))
+          h3({ class: 'stats-section-h' }, i18n.statsLogsTitle || 'Logs'),
+          ul({ class: 'stats-list-reset' },
+            li({ class: 'stats-h-row' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ class: 'stats-muted-888' }, String(stats?.logsCount || 0)))
           )
         ),
         div({ style: headerStyle },
-          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsAITraining),
-          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsAIExchanges}: `, span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0)))
+          h3({ class: 'stats-section-h' }, i18n.statsAITraining),
+          ul({ class: 'stats-list-reset' },
+            li({ class: 'stats-h-row' }, `${i18n.statsAIExchanges}: `, span({ class: 'stats-muted-888' }, String(C(stats, 'aiExchange') || 0)))
           )
         ),
         div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
@@ -233,7 +233,7 @@ exports.statsView = (stats, filter) => {
           ? div({ class: 'stats-container' }, [
               div({ style: blockStyle },
                 h2(i18n.statsActivity7d),
-                table({ style: 'width:100%; border-collapse: collapse;' },
+                table({ class: 'stats-table' },
                   tr(th(i18n.day), th(i18n.messages)),
                   ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
                 ),
@@ -242,7 +242,7 @@ exports.statsView = (stats, filter) => {
               ),
               div({ style: blockStyle },
                 h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
-                table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                table({ class: 'stats-table-mt8' },
                   ...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
                 )
               ),
@@ -317,7 +317,7 @@ exports.statsView = (stats, filter) => {
             ? div({ class: 'stats-container' }, [
                 div({ style: blockStyle },
                   h2(i18n.statsActivity7d),
-                  table({ style: 'width:100%; border-collapse: collapse;' },
+                  table({ class: 'stats-table' },
                     tr(th(i18n.day), th(i18n.messages)),
                     ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
                   ),
@@ -326,14 +326,14 @@ exports.statsView = (stats, filter) => {
                 ),
                 div({ style: blockStyle },
                   h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
-                  table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                  table({ class: 'stats-table-mt8' },
                     ...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
                   )
                 ),
                 Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
                   ? div({ style: blockStyle },
                       h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
-                      table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                      table({ class: 'stats-table-mt8' },
                         ...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))))
                       )
                     )

+ 2 - 0
src/views/torrents_view.js

@@ -205,6 +205,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => {
 
 const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
   const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
+  const tribeId = safeText(params.tribeId || "");
   return div(
     { class: "div-center audio-form" },
     form(
@@ -214,6 +215,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
         enctype: "multipart/form-data"
       },
       input({ type: "hidden", name: "returnTo", value: returnTo }),
+      tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
       span(i18n.torrentFileLabel),
       br(),
       input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }),

+ 216 - 91
src/views/tribes_view.js

@@ -1,4 +1,5 @@
-const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
+const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = require("../server/node_modules/hyperaxe");
+const moment = require("../server/node_modules/moment");
 const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
@@ -197,7 +198,8 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
   );
 
   const isEdit = filter === 'edit' && tribeId;
-  const tribeToEdit = isEdit ? tribes.find(t => t.id === tribeId) : {};
+  const tribeToEdit = (isEdit ? tribes.find(t => t.id === tribeId) : null) || {};
+  const isSubEdit = isEdit && !!tribeToEdit.parentTribeId;
   const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
     h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
     form({
@@ -229,13 +231,14 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
     br,
     input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
     br,
-    label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
-    br,
-    select({ name: 'isAnonymous', id: 'isAnonymous' },
+    isSubEdit ? null : label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
+    isSubEdit ? null : br,
+    isSubEdit ? null : select({ name: 'isAnonymous', id: 'isAnonymous' },
       option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
       option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
     ),
-    br(), br(),
+    isSubEdit ? null : br(),
+    isSubEdit ? null : br(),
     label({ for: 'inviteMode' }, i18n.tribeModeLabel),
     br,
     select({ name: 'inviteMode', id: 'inviteMode' },
@@ -243,13 +246,14 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
       option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
     ),
     br(), br(),
-    label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
-    br,
-    select({ name: 'isLARP', id: 'isLARP' },
+    isSubEdit ? null : label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
+    isSubEdit ? null : br,
+    isSubEdit ? null : select({ name: 'isLARP', id: 'isLARP' },
       option({ value: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
       option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
     ),
-    br(), br(),
+    isSubEdit ? null : br(),
+    isSubEdit ? null : br(),
     button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
     )
   ) : null;
@@ -258,27 +262,16 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
 
   const tribeCards = sorted.map(t => {
     const isMember = t.members.includes(userId);
-    const subtribes = allT.filter(st => st.parentTribeId === t.id);
-
     const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
 
     return div({ class: 'tribe-card' },
-      parentTribe
-        ? div({ class: 'tribe-card-parent' },
-            span({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'),
-            a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}`, class: 'tribe-parent-card-link' },
-              renderMediaBlob(parentTribe.image, '/assets/images/default-tribe.png', { class: 'tribe-parent-image', alt: parentTribe.title }),
-              span({ class: 'tribe-parent-card-title' }, parentTribe.title)
-            )
-          )
-        : null,
       div({ class: 'tribe-card-image-wrapper' },
         a({ href: `/tribe/${encodeURIComponent(t.id)}` },
           renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' })
         ),
         isMember
           ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-visit-btn-wrapper' },
-              button({ type: 'submit', class: 'filter-btn' }, String(i18n.tribeviewTribeButton || '').toUpperCase())
+              button({ type: 'submit', class: 'filter-btn' }, t.parentTribeId ? String(i18n.tribeviewSubTribeButton || 'VISIT SUB-TRIBE').toUpperCase() : String(i18n.tribeviewTribeButton || '').toUpperCase())
             )
           : null
       ),
@@ -287,32 +280,26 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
         t.description ? p({ class: 'tribe-card-description' }, ...renderUrl(t.description)) : null,
         renderMapLocationVisitLabel(t.mapUrl),
         table({ class: 'tribe-info-table' },
+          parentTribe ? tr(
+            td({ class: 'tribe-info-label' }, i18n.tribeRootLabel || 'ROOT'),
+            td({ class: 'tribe-info-value', colspan: '3' }, a({ href: `/tribe/${encodeURIComponent(parentTribe.id)}` }, parentTribe.title))
+          ) : null,
           t.location ? tr(
             td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
             td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location))
           ) : null,
-          tr(
+          !t.parentTribeId ? tr(
             td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
             td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
-          ),
+          ) : null,
           tr(
             td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
             td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
           ),
-          tr(
+          !t.parentTribeId ? tr(
             td({ class: 'tribe-info-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
             td({ class: 'tribe-info-value', colspan: '3' }, t.isLARP ? i18n.tribeYes : i18n.tribeNo)
-          )
-        ),
-        div({ class: 'tribe-card-subtribes' },
-          span({ class: 'tribe-info-label' }, i18n.tribeSubTribes || 'SUB-TRIBES'),
-          subtribes.length > 0
-            ? subtribes.map(st =>
-                form({ method: 'GET', action: `/tribe/${encodeURIComponent(st.id)}` },
-                  button({ type: 'submit', class: 'tribe-subtribe-link' }, st.title)
-                )
-              )
-            : span({ class: 'tribe-info-empty' }, '—')
+          ) : null
         ),
         div({ class: 'tribe-card-members' },
           span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.members.length}`)
@@ -400,17 +387,17 @@ const sectionLink = (tribe, sectionKey, label, currentSection) =>
 const renderSectionNav = (tribe, section) => {
   const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
   if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
-  firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
+  if (!tribe.parentTribeId) firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
   const sections = [
     { items: firstGroup },
     { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
     { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }, { key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
     { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] },
-    { items: [{ key: 'search', label: i18n.tribeSectionSearch }] },
+    { items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] },
   ];
-  return div({ class: 'tribe-section-nav', style: 'border: none;' },
+  return div({ class: 'tribe-section-nav no-border' },
     sections.map(g =>
-      div({ class: 'tribe-section-group', style: 'border: none;' },
+      div({ class: 'tribe-section-group no-border' },
         g.items.map(s => s.href
           ? form({ method: 'GET', action: s.href },
               button({ type: 'submit', class: 'filter-btn' }, s.label)
@@ -487,13 +474,14 @@ const contentTypeName = (ct) => {
 const activitySectionMap = {
   event: 'events', task: 'tasks', votation: 'votations',
   forum: 'forum', 'forum-reply': 'forum',
-  feed: 'feed'
+  feed: 'feed',
+  pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents'
 };
 
 const activitySectionForItem = (item) => {
   if (item.contentType === 'media' && item.mediaType) {
-    const map = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks' };
-    return map[item.mediaType] || 'images';
+    const map = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' };
+    return map[item.mediaType] || 'media';
   }
   return activitySectionMap[item.contentType] || 'activity';
 };
@@ -507,7 +495,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
   const { activities } = sectionData || { activities: [] };
   if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty));
   const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
-  return div({ class: 'tribe-content-list', style: 'gap: 16px; display: flex; flex-direction: column;' },
+  return div({ class: 'tribe-content-list tribe-content-list-spaced' },
     activities.slice(0, 50).map(item => {
       if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')));
       const date = item.timestamp ? new Date(item.timestamp).toLocaleString() : '';
@@ -531,7 +519,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
         : item.contentType === 'media' && item.mediaType === 'bookmark' && (item.url || item.description)
           ? a({ href: item.url || item.description, target: '_blank', class: 'tribe-action-btn' }, item.url || item.description)
         : null;
-      return div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      return div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, headerText),
           item.directUrl
@@ -543,7 +531,6 @@ const renderTribeActivitySection = (tribe, sectionData) => {
         ),
         div({ class: 'tribe-card-body' },
           item.title ? div({ class: 'card-field' },
-            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
             span({ class: 'card-value' }, item.title)
           ) : null,
           mediaContent,
@@ -587,6 +574,51 @@ const renderTribeTrendingSection = (tribe, sectionData, query) => {
   );
 };
 
+const renderTribeTagsSection = (tribe, sectionData, query) => {
+  const tagsList = Array.isArray(sectionData?.tags) ? sectionData.tags : [];
+  const selectedTag = sectionData?.selectedTag || '';
+  const filteredItems = Array.isArray(sectionData?.filteredItems) ? sectionData.filteredItems : [];
+  const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
+  const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
+  return section(
+    div({ class: 'tags-header' },
+      h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'),
+      p(i18n.tagsDescription || '')
+    ),
+    div({ class: 'tags-list' },
+      tagsList.length === 0
+        ? p(i18n.tagsNoItems || i18n.tribeTagsEmpty || 'No tags yet')
+        : table({ class: 'tag-table' },
+            thead(tr(
+              th(i18n.tagsTableHeaderTag || 'Tag'),
+              th(i18n.tagsTableHeaderCount || 'Count')
+            )),
+            tbody(
+              sortedTags.map(t => tr(
+                td(a({ href: `${tribeUrl}?section=tags&tag=${encodeURIComponent(t.tag)}` }, t.tag)),
+                td(`${t.count}`)
+              ))
+            )
+          )
+    ),
+    selectedTag ? div({ class: 'tribe-content-list' },
+      h2(`#${selectedTag} (${filteredItems.length})`),
+      filteredItems.length === 0 ? p(i18n.tribeTagsEmpty || 'No items') :
+        filteredItems.slice(0, 50).map(item => div({ class: 'card card-rpg' },
+          div({ class: 'card-header' }, h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`)),
+          div({ class: 'card-body' },
+            item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
+            item.description ? p(item.description.substring(0, 200)) : null
+          ),
+          p({ class: 'card-footer' },
+            span({ class: 'date-link' }, new Date(item.createdAt).toLocaleString()),
+            a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
+          )
+        ))
+    ) : null
+  );
+};
+
 const renderTribeSearchSection = (tribe, sectionData, query) => {
   const { results } = sectionData || { results: [] };
   const sq = sectionData?.query || '';
@@ -1235,10 +1267,6 @@ const renderSubTribesSection = (tribe, items, query) => {
     : tribe.author === userId;
 
   if (action === 'create' && canCreate) {
-    const parentIsPrivate = !!tribe.isAnonymous;
-    const statusOptions = parentIsPrivate
-      ? [{ value: 'true', label: (i18n.tribeSubInheritedPrivate || i18n.tribePrivate) }]
-      : [{ value: 'true', label: i18n.tribePrivate }, { value: 'false', label: i18n.tribePublic }];
     return renderCreateForm(tribe, 'subtribes', [
       { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' },
       { name: 'description', type: 'textarea', label: i18n.tribeDescriptionLabel, required: true, placeholder: 'Description of the sub-tribe' },
@@ -1249,10 +1277,6 @@ const renderSubTribesSection = (tribe, items, query) => {
       { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [
         { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
       ], spaceBefore: true },
-      { name: 'isAnonymous', type: 'select', label: i18n.tribeIsAnonymousLabel, options: statusOptions, spaceBefore: true },
-      { name: 'isLARP', type: 'select', label: 'L.A.R.P.?', options: [
-        { value: 'false', label: i18n.tribeNo }, { value: 'true', label: i18n.tribeYes }
-      ], spaceBefore: true },
     ]);
   }
 
@@ -1265,6 +1289,9 @@ const renderSubTribesSection = (tribe, items, query) => {
       input({ type: 'hidden', name: 'action', value: 'create' }),
       button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
     ) : null,
+    !canCreate && tribe.inviteMode === 'strict'
+      ? p({ class: 'tribe-strict-denied' }, i18n.tribeSubTribesStrictDenied)
+      : null,
     subTribes.length === 0
       ? null
       : div({ class: 'tribe-thumb-grid' },
@@ -1287,7 +1314,7 @@ const renderTribeMapsSection = (tribe, maps) => {
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`),
           form({ method: 'GET', action: `/maps/${encodeURIComponent(m.key)}` },
@@ -1310,6 +1337,45 @@ const renderTribeMapsSection = (tribe, maps) => {
   );
 };
 
+const renderTribeTorrentsSection = (tribe, torrents) => {
+  const items = Array.isArray(torrents) ? torrents : [];
+  const createBtn = form({ method: 'GET', action: '/torrents' },
+    input({ type: 'hidden', name: 'filter', value: 'create' }),
+    input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
+    button({ type: 'submit', class: 'create-button' }, i18n.tribeCreateTorrent || 'Upload Torrent'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn), p(i18n.tribeTorrentsEmpty || 'No torrents, yet.'));
+  return div({ class: 'tribe-content-list' },
+    div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTorrents || 'TORRENTS'), createBtn),
+    items.map(m => {
+      const blobName = encodeURIComponent((m.title || 'download').replace(/\.torrent$/i, '') + '.torrent');
+      const blobUrl = m.url ? `/blob/${encodeURIComponent(m.url)}?name=${blobName}` : null;
+      return div({ class: 'card card-rpg tribe-card-padded' },
+        div({ class: 'card-header' },
+          h2({ class: 'card-label' }, `[${(i18n.typeTorrent || 'TORRENT').toUpperCase()}]`),
+          m._isMedia
+            ? (blobUrl ? a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download') : null)
+            : form({ method: 'GET', action: `/torrents/${encodeURIComponent(m.rootId || m.key)}` },
+                button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View Details'))
+        ),
+        div({ class: 'tribe-card-body' },
+          m.title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
+            span({ class: 'card-value' }, m._isMedia
+              ? (blobUrl ? a({ href: blobUrl }, m.title) : m.title)
+              : a({ href: `/torrents/${encodeURIComponent(m.rootId || m.key)}` }, m.title))
+          ) : null,
+          m.description ? p(String(m.description).substring(0, 200)) : null,
+          blobUrl && !m._isMedia ? div({ class: 'card-field' }, a({ href: blobUrl, class: 'filter-btn' }, i18n.torrentDownload || 'Download')) : null
+        ),
+        p({ class: 'card-footer' },
+          span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
+          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+        )
+      );
+    })
+  );
+};
+
 const renderTribePadsSection = (tribe, pads) => {
   const items = Array.isArray(pads) ? pads : [];
   const createBtn = form({ method: 'GET', action: '/pads' },
@@ -1320,7 +1386,7 @@ const renderTribePadsSection = (tribe, pads) => {
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
           form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
@@ -1351,7 +1417,7 @@ const renderTribeChatsSection = (tribe, chats) => {
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
           form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
@@ -1383,7 +1449,7 @@ const renderTribeCalendarsSection = (tribe, calendars) => {
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
           form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` },
@@ -1424,8 +1490,8 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
     case 'inhabitants': sectionContent = renderInhabitantsSection(tribe, sectionData); break;
     case 'feed':
       sectionContent = div(
-        query.sent ? div({ class: 'card card-rpg', style: 'padding: 12px 16px; margin-bottom: 16px; text-align: center;' },
-          p({ style: 'font-weight: bold;' }, i18n.tribeFeedSent || 'Message sent successfully!')
+        query.sent ? div({ class: 'card card-rpg tribe-banner' },
+          p({ class: 'bold' }, i18n.tribeFeedSent || 'Message sent successfully!')
         ) : null,
         tribe.members.includes(config.keys.id)
           ? form({ class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
@@ -1447,12 +1513,14 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
     case 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break;
     case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
     case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break;
-    case 'torrents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'torrent'); break;
+    case 'torrents': sectionContent = renderTribeTorrentsSection(tribe, sectionData); break;
     case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
     case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
     case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
     case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
     case 'governance': sectionContent = renderGovernance(tribe, sectionData); break;
+    case 'trending': sectionContent = renderTribeTrendingSection(tribe, sectionData, query); break;
+    case 'tags': sectionContent = renderTribeTagsSection(tribe, sectionData, query); break;
     case 'activity':
     default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
   }
@@ -1543,8 +1611,8 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
       )) : null,
     ),
     div({ class: 'tribe-main' },
-      query.inviteCode ? div({ class: 'card card-rpg', style: 'padding: 12px 16px; margin-bottom: 16px; text-align: center;' },
-        p({ style: 'font-weight: bold;' }, i18n.tribeInviteCodeText, query.inviteCode)
+      query.inviteCode ? div({ class: 'card card-rpg tribe-banner' },
+        p({ class: 'bold' }, i18n.tribeInviteCodeText, query.inviteCode)
       ) : null,
       renderSectionNav(tribe, section),
       sectionContent
@@ -1561,16 +1629,16 @@ const GOVERNANCE_METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP',
 
 const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
   const row1 = [
-    { key: 'government', label: i18n.parliamentFilterGovernment || 'GOVERNMENT' },
-    { key: 'candidatures', label: i18n.parliamentFilterCandidatures || 'CANDIDATURES' },
-    { key: 'proposals', label: i18n.parliamentFilterProposals || 'PROPOSALS' },
-    { key: 'laws', label: i18n.parliamentFilterLaws || 'LAWS' }
+    { key: 'government', label: i18n.tribeGovFilterGovernment || 'GOVERNMENT' },
+    { key: 'candidatures', label: i18n.tribeGovFilterCandidatures || 'CANDIDATURES' },
+    { key: 'proposals', label: i18n.tribeGovProposals || 'PROPOSALS' },
+    { key: 'laws', label: i18n.tribeGovFilterLaws || 'LAWS' }
   ];
   const row2 = [
-    { key: 'revocations', label: i18n.parliamentFilterRevocations || 'REVOCATIONS' },
-    { key: 'historical', label: i18n.parliamentFilterHistorical || 'HISTORICAL' },
-    { key: 'leaders', label: i18n.parliamentFilterLeaders || 'LEADERS' },
-    { key: 'rules', label: i18n.parliamentFilterRules || 'RULES' }
+    { key: 'revocations', label: i18n.tribeGovRevocations || 'REVOCATIONS' },
+    { key: 'historical', label: i18n.tribeGovHistorical || 'HISTORICAL' },
+    { key: 'leaders', label: i18n.tribeGovLeader || 'LEADERS' },
+    { key: 'rules', label: i18n.tribeGovRules || 'RULES' }
   ];
   const mkRow = (filters) => div({ class: 'mode-buttons-row' },
     filters.map(f =>
@@ -1587,13 +1655,26 @@ const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
     showPublish
       ? div({ class: 'mode-buttons-row' },
           form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribeId)}/governance/publish-candidature` },
-            button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn || 'Publish Candidature')
+            button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature')
           )
         )
       : null
   );
 };
 
+const fmtTermDate = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
+const fmtTimeLeft = (end) => {
+  const diff = moment(end).diff(moment());
+  if (diff <= 0) return '0d 00:00:00';
+  const dur = moment.duration(diff);
+  const d = Math.floor(dur.asDays());
+  const h = String(dur.hours()).padStart(2, '0');
+  const m = String(dur.minutes()).padStart(2, '0');
+  const s = String(dur.seconds()).padStart(2, '0');
+  return `${d}d ${h}:${m}:${s}`;
+};
+const methodImageSrc = (method) => `/assets/images/${String(method || '').toLowerCase()}.png`;
+
 const governmentCard = (tribe, term, leaders) => {
   if (!term) {
     return div({ class: 'card' },
@@ -1601,13 +1682,54 @@ const governmentCard = (tribe, term, leaders) => {
       p(i18n.tribeGovernanceNoGovDesc || 'This tribe has not yet elected a government. Propose candidatures to start the process.')
     );
   }
+  const method = String(term.method || 'ANARCHY').toUpperCase();
+  const isAnarchy = method === 'ANARCHY';
+  const i18nMeth = i18n[`parliamentMethod${method}`];
+  const methodLabel = (i18nMeth && String(i18nMeth).trim() ? String(i18nMeth) : method).toUpperCase();
+  const termStart = term.startAt || moment().toISOString();
+  const termEnd = term.endAt || moment(termStart).add(60, 'days').toISOString();
+  const population = Array.isArray(tribe.members) ? tribe.members.length : 0;
+  const winnerVotes = Number(term.winnerVotes || 0);
+  const totalVotes = Number(term.totalVotes || 0);
+  const votesDisplay = `${winnerVotes} (${totalVotes})`;
+  const leaderId = term.leaderId || (Array.isArray(leaders) && leaders[0]) || null;
   return div({ class: 'card' },
-    h3(`${i18n.parliamentMethod || 'Method'}: ${term.method || 'DEMOCRACY'}`),
-    p(`${i18n.parliamentTerm || 'Term'}: ${term.startAt?.slice(0, 10) || '-'} → ${term.endAt?.slice(0, 10) || '-'}`),
-    Array.isArray(leaders) && leaders.length > 0
-      ? div({},
-          h3(i18n.parliamentFilterLeaders || 'LEADERS'),
-          ul({}, leaders.map(l => li({}, `@${l.slice(0, 12)}…`)))
+    h2(i18n.tribeGovCardTitle || 'Current Government'),
+    div({ class: 'cycle-info' },
+      div({ class: 'kpi' },
+        span({ class: 'kpi__label' }, ((i18n.tribeGovCycleSince || 'CYCLE SINCE') + ': ').toUpperCase()),
+        span({ class: 'kpi__value' }, fmtTermDate(termStart))
+      ),
+      div({ class: 'kpi' },
+        span({ class: 'kpi__label' }, ((i18n.tribeGovCycleEnd || 'CYCLE END') + ': ').toUpperCase()),
+        span({ class: 'kpi__value' }, fmtTermDate(termEnd))
+      ),
+      div({ class: 'kpi' },
+        span({ class: 'kpi__label' }, ((i18n.tribeGovTimeRemaining || 'TIME REMAINING') + ': ').toUpperCase()),
+        span({ class: 'kpi__value' }, fmtTimeLeft(termEnd))
+      ),
+      div({ class: 'kpi' },
+        span({ class: 'kpi__label' }, ((i18n.tribeGovPopulation || 'POPULATION') + ': ').toUpperCase()),
+        span({ class: 'kpi__value' }, String(population))
+      ),
+      div({ class: 'kpi' },
+        span({ class: 'kpi__label' }, ((i18n.tribeGovMethod || 'METHOD') + ': ').toUpperCase()),
+        span({ class: 'kpi__value' }, methodLabel)
+      ),
+      !isAnarchy
+        ? div({ class: 'kpi' },
+            span({ class: 'kpi__label' }, ((i18n.tribeGovVotesReceived || 'VOTES RECEIVED') + ': ').toUpperCase()),
+            span({ class: 'kpi__value' }, votesDisplay)
+          )
+        : null
+    ),
+    div({ class: 'method-image-centered' },
+      img({ src: methodImageSrc(method), alt: methodLabel })
+    ),
+    !isAnarchy && leaderId
+      ? div({ class: 'tribe-leader-block' },
+          h3(i18n.tribeGovLeader || 'LEADER'),
+          a({ href: `/author/${encodeURIComponent(leaderId)}`, class: 'user-link' }, leaderId)
         )
       : null
   );
@@ -1623,22 +1745,25 @@ const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle)
       : null,
     h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'),
     form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/propose` },
-      label({}, i18n.parliamentCandidatureId || 'Candidate'), br(),
+      label({}, i18n.tribeGovCandidatureId || 'Candidate'), br(),
       input({ type: 'text', name: 'candidateId', placeholder: '@...ed25519', required: true }), br(), br(),
-      label({}, i18n.parliamentCandidatureMethod || 'Method'), br(),
+      label({}, i18n.tribeGovCandidatureMethod || 'Method'), br(),
       select({ name: 'method' },
         GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
       ), br(), br(),
-      button({ type: 'submit', class: 'create-button' }, i18n.parliamentCandidatureProposeBtn || 'Publish Candidature')
+      button({ type: 'submit', class: 'create-button' }, i18n.tribeGovCandidatureProposeBtn || 'Publish Candidature')
     ),
     h3(i18n.tribeGovernanceInternalCandidatures || 'Internal candidatures'),
     list.length === 0
       ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.')
       : ul({}, list.map(c =>
           li({},
-            `${c.candidateId?.slice(0, 16) || '?'}… — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`,
+            c.candidateId
+              ? a({ href: `/author/${encodeURIComponent(c.candidateId)}`, class: 'user-link' }, c.candidateId)
+              : '?',
+            ` — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`,
             ' ',
-            form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/vote`, style: 'display:inline' },
+            form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/vote`, class: 'inline-form' },
               input({ type: 'hidden', name: 'candidatureId', value: c.id }),
               button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote')
             )
@@ -1652,23 +1777,23 @@ const rulesBlock = (tribe, rules, isCreator) => div({},
     ? div({},
         h3(i18n.tribeGovernanceAddRule || 'Add rule'),
         form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
-          label({}, i18n.parliamentRuleTitle || 'Title'), br(),
+          label({}, i18n.tribeGovRuleTitle || 'Title'), br(),
           input({ type: 'text', name: 'title', required: true }), br(), br(),
-          label({}, i18n.parliamentRuleBody || 'Body'), br(),
+          label({}, i18n.tribeGovRuleBody || 'Body'), br(),
           textarea({ name: 'body', rows: 4 }), br(), br(),
           button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
         )
       )
     : null,
-  h3(i18n.parliamentFilterRules || 'Rules'),
+  h3(i18n.tribeGovRules || 'Rules'),
   (!Array.isArray(rules) || rules.length === 0)
     ? p(i18n.tribeGovernanceNoRules || 'No rules yet.')
     : ul({}, rules.map(r =>
         li({},
-          span({ style: 'font-weight:bold' }, r.title || '-'),
+          span({ class: 'bold' }, r.title || '-'),
           r.body ? p(r.body) : null,
           isCreator
-            ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/delete`, style: 'display:inline' },
+            ? form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/delete`, class: 'inline-form' },
                 input({ type: 'hidden', name: 'ruleId', value: r.id }),
                 button({ type: 'submit', class: 'filter-btn' }, i18n.delete || 'Delete')
               )
@@ -1689,13 +1814,13 @@ const renderGovernance = (tribe, data) => {
   else if (f === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle);
   else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator);
   else if (f === 'leaders') body = div({ class: 'card' },
-    h3(i18n.parliamentFilterLeaders || 'LEADERS'),
+    h3(i18n.tribeGovLeader || 'LEADERS'),
     (!Array.isArray(leaders) || leaders.length === 0)
       ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.')
-      : ul({}, leaders.map(l => li({}, `@${l.slice(0, 12)}…`)))
+      : ul({}, leaders.map(l => li({}, a({ href: `/author/${encodeURIComponent(l)}`, class: 'user-link' }, l))))
   );
   else body = div({ class: 'card' },
-    h3(i18n[`parliamentFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()),
+    h3(i18n[`tribeGovFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || i18n[`tribeGov${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()),
     p(i18n.tribeGovernanceComingSoon || 'Coming soon in this tribe\'s governance module.')
   );