Browse Source

Oasis release 0.7.5

psy 9 hours ago
parent
commit
31ad98e62d
36 changed files with 1699 additions and 497 deletions
  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
 ### 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
 ## v0.7.4 - 2026-04-25
 
 
 ### Added
 ### Added

+ 263 - 53
src/backend/backend.js

@@ -61,7 +61,7 @@ const ensureTerm = async () => {
 let sweepInFlight = null;
 let sweepInFlight = null;
 const runSweepOnce = async () => {
 const runSweepOnce = async () => {
   if (sweepInFlight) return sweepInFlight;
   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;
   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 votesModel = require('../models/votes_model')({ cooler, isPublic: config.public });
 const ssbConfig = require('../server/ssb_config');
 const ssbConfig = require('../server/ssb_config');
 const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path);
 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 reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
 const transfersModel = require('../models/transfers_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 cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_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 feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
 const imagesModel = require("../models/images_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 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 videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
 const documentsModel = require("../models/documents_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 agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public });
 const trendingModel = require('../models/trending_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 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 padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, tribesModel });
 const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, 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 });
 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 shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel });
 const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 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 gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
 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 });
 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)) }));
     let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) }));
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     const myTribeIds = await getUserTribeIds(uid);
     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);
     enriched = await applyListFilters(enriched, ctx);
     try {
     try {
       ctx.body = await mapsView(enriched, filter, null, { q, lat, lng, zoom, title, description, markerLabel, tags, mapType, ...(tribeId ? { tribeId } : {}) });
       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) => {
   .get("/maps/edit/:id", async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     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');
     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 || '' });
     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; }
     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 { mapId } = ctx.params; const { filter = 'all', q = '', zoom = '0', mkLat = '', mkLng = '', label: mkMarkerLabel = '' } = ctx.query;
     const uid = getViewerId();
     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');
     const fav = await mediaFavorites.getFavoriteSet('maps');
     let tribeMembers = [];
     let tribeMembers = [];
     let parentTribe = null;
     let parentTribe = null;
@@ -1225,13 +1235,13 @@ router
   })
   })
   .get("/torrents", async (ctx) => {
   .get("/torrents", async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
     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 items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() });
     const fav = await mediaFavorites.getFavoriteSet('torrents');
     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);
     if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite);
     enriched = await applyListFilters(enriched, ctx);
     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) => {
   .get("/torrents/edit/:id", async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
@@ -1575,6 +1585,7 @@ router
     if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
     await tribesModel.processIncomingKeys().catch(() => {});
     await tribesModel.processIncomingKeys().catch(() => {});
     await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {});
     await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {});
+    await tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {});
     const listByTribeAllChain = async (tribeId, contentType) => {
     const listByTribeAllChain = async (tribeId, contentType) => {
       const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
       const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
       const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => [])));
       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(() => []);
         const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
         subContent.push(...stItems.map(item => ({ ...item, tribeName: st.title })));
         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(() => []),
         padsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         chatsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         calendarsModel.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 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 = [
       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 combined = [...allContent, ...subContent, ...standaloneItems];
       const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
       const allInhabitants = await inhabitantsModel.listInhabitants({ filter: 'all', includeInactive: true });
@@ -1647,10 +1662,29 @@ router
       sectionData = { items, period };
       sectionData = { items, period };
     } else if (section === 'tags') {
     } else if (section === 'tags') {
       const allContent = await listByTribeAllChain(tribe.id, null);
       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();
       const tagMap = new Map();
-      for (const item of allContent) {
+      for (const item of allTaggable) {
         for (const tag of (item.tags || []).filter(Boolean)) {
         for (const tag of (item.tags || []).filter(Boolean)) {
-          const lower = tag.toLowerCase().trim();
+          const lower = String(tag).toLowerCase().trim();
           if (!lower) continue;
           if (!lower) continue;
           if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] });
           if (!tagMap.has(lower)) tagMap.set(lower, { tag: lower, count: 0, items: [] });
           const entry = tagMap.get(lower);
           const entry = tagMap.get(lower);
@@ -1661,17 +1695,56 @@ router
       const selectedTag = (ctx.query.tag || '').toLowerCase().trim();
       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 : [] };
       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') {
     } 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') {
     } 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') {
     } 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') {
     } 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') {
     } else if (section === 'search') {
       const sq = (ctx.query.q || '').trim().toLowerCase();
       const sq = (ctx.query.q || '').trim().toLowerCase();
       let results = [];
       let results = [];
@@ -1698,9 +1771,11 @@ router
       const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
       const feed = await listByTribeAllChain(tribe.id, 'feed').catch(() => []);
       sectionData = { events, tasks, feed };
       sectionData = { events, tasks, feed };
     } else if (section === 'governance') {
     } else if (section === 'governance') {
+      if (tribe.parentTribeId) { ctx.redirect(`/tribe/${encodeURIComponent(tribe.id)}?section=activity`); return; }
       const gf = String(ctx.query.filter || 'government');
       const gf = String(ctx.query.filter || 'government');
       const isCreator = tribe.author === uid;
       const isCreator = tribe.author === uid;
       const isMember = Array.isArray(tribe.members) && tribe.members.includes(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([
       const [term, candidatures, rules, globalTermBase] = await Promise.all([
         parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null),
         parliamentModel.tribe.getCurrentTerm(tribe.id).catch(() => null),
         parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []),
         parliamentModel.tribe.listCandidatures(tribe.id).catch(() => []),
@@ -2187,7 +2262,7 @@ router
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId });
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const myTribeIds = await getUserTribeIds(viewerId);
     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;
     let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched;
     finalList = await applyListFilters(finalList, ctx);
     finalList = await applyListFilters(finalList, ctx);
     ctx.body = await chatsView(finalList, filter, null, { q });
     ctx.body = await chatsView(finalList, filter, null, { q });
@@ -2213,13 +2288,10 @@ router
         chat = await chatsModel.getChatById(ctx.params.chatId);
         chat = await chatsModel.getChatById(ctx.params.chatId);
       } catch { ctx.redirect('/tribes'); return; }
       } 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 fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
     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) => {
   .get("/pads", async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
@@ -2255,10 +2327,6 @@ router
         pad = await padsModel.getPadById(ctx.params.padId);
         pad = await padsModel.getPadById(ctx.params.padId);
       } catch { ctx.redirect('/tribes'); return; }
       } 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 fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
     const entries = await padsModel.getEntries(pad.rootId);
     const versionKey = ctx.query.version || null;
     const versionKey = ctx.query.version || null;
@@ -2266,7 +2334,8 @@ router
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       ? (entries.find(e => e.key === versionKey) || entries[parseInt(versionKey)] || null)
       : null;
       : null;
     const baseUrl = `${ctx.protocol}://${ctx.host}`;
     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) => {
   .get("/calendars", async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
@@ -2286,7 +2355,7 @@ router
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('calendars');
     const fav = await mediaFavorites.getFavoriteSet('calendars');
     const myTribeIds = await getUserTribeIds(uid);
     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;
     let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched;
     finalList = await applyListFilters(finalList, ctx);
     finalList = await applyListFilters(finalList, ctx);
     ctx.body = await calendarsView(finalList, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
     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) => {
   .post("/maps/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
     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 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 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']);
     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) => {
   .post("/maps/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     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 b = ctx.request.body;
     const imageId = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) || "" : "";
     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);
     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) => {
   .post("/maps/delete/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     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);
     await mapsModel.deleteMapById(ctx.params.id);
     ctx.redirect(safeReturnTo(ctx, '/maps?filter=mine', ['/maps']));
     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("/audios/:audioId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'audios', 'audioId'))
   .post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
   .post("/torrents/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
     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 blob = await handleBlobUpload(ctx, 'torrent');
     const fileSize = ctx.request.files?.torrent?.size || 0;
     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) => {
   .post("/torrents/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; }
     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 { tags, title, description } = ctx.request.body;
     const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null;
     const blob = ctx.request.files?.torrent ? await handleBlobUpload(ctx, 'torrent') : null;
     await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
     await torrentsModel.updateTorrentById(ctx.params.id, blob, stripDangerousTags(tags), stripDangerousTags(title), stripDangerousTags(description));
     ctx.redirect(safeReturnTo(ctx, '/torrents?filter=mine', ['/torrents']));
     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/add/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'add'))
   .post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove'))
   .post("/torrents/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'torrents', 'remove'))
   .post("/torrents/:torrentId/comments", koaBodyMiddleware, async ctx => commentAction(ctx, 'torrents', 'torrentId'))
   .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 (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; }
     if (!['strict', 'open'].includes(b.inviteMode)) { ctx.redirect('/tribes'); return; }
     const image = await handleBlobUpload(ctx, 'image');
     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');
     ctx.redirect('/tribes');
   })
   })
   .post('/tribe/:id/subtribes/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
   .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;
     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 image = await handleBlobUpload(ctx, 'image');
     const parentEffective = await tribesModel.getEffectiveStatus(ctx.params.id).catch(() => ({ isPrivate: false }));
     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`);
     ctx.redirect(`/tribe/${encodeURIComponent(ctx.params.id)}?section=subtribes`);
   })
   })
   .post('/tribes/update/:id', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
   .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 (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; }
     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) : [];
     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');
     ctx.redirect('/tribes?filter=mine');
   })
   })
   .post('/tribes/delete/:id', async ctx => {
   .post('/tribes/delete/:id', async ctx => {
@@ -3513,6 +3629,7 @@ router
     const uid = getViewerId();
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
     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 isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
     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);
     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.');
     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 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)));
     await parliamentModel.proposeCandidature({ candidateId: tribeId, method }).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=candidatures');
     ctx.redirect('/parliament?filter=candidatures');
   })
   })
@@ -3529,6 +3647,7 @@ router
     const uid = getViewerId();
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
     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 isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
@@ -3544,6 +3663,7 @@ router
     const uid = getViewerId();
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
     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 isCreator = tribe.author === uid;
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     const isMember = Array.isArray(tribe.members) && tribe.members.includes(uid);
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
     if (!isCreator && !isMember) ctx.throw(403, 'Not a tribe member');
@@ -3557,6 +3677,7 @@ router
     const uid = getViewerId();
     const uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
     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');
     if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can add rules');
     const b = ctx.request.body || {};
     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)));
     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 uid = getViewerId();
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     const tribe = await tribesModel.getTribeById(tribeId).catch(() => null);
     if (!tribe) ctx.throw(404, 'Tribe not found');
     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');
     if (tribe.author !== uid) ctx.throw(403, 'Only tribe creator can delete rules');
     const ruleId = String(ctx.request.body?.ruleId || '').trim();
     const ruleId = String(ctx.request.body?.ruleId || '').trim();
     if (!ruleId) ctx.throw(400, 'Missing ruleId');
     if (!ruleId) ctx.throw(400, 'Missing ruleId');
@@ -3881,8 +4003,12 @@ router
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body;
     const b = ctx.request.body;
     const tribeId = b.tribeId || null;
     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;
     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);
     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']));
     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) => {
   .post("/chats/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     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 {
     try {
       await chatsModel.joinChat(ctx.params.id);
       await chatsModel.joinChat(ctx.params.id);
     } catch (_) {}
     } catch (_) {}
@@ -3957,7 +4096,11 @@ router
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {};
     const b = ctx.request.body || {};
     const tribeId = b.tribeId || null;
     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(
     const msg = await padsModel.createPad(
       stripDangerousTags(b.title || ""),
       stripDangerousTags(b.title || ""),
       b.status || "OPEN",
       b.status || "OPEN",
@@ -4006,6 +4149,18 @@ router
   .post("/pads/join/:id", koaBody(), async (ctx) => {
   .post("/pads/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const uid = getViewerId();
     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);
     await padsModel.addMemberToPad(ctx.params.id, uid);
     ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
     ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
   })
   })
@@ -4030,6 +4185,10 @@ router
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {};
     const b = ctx.request.body || {};
     const tribeId = b.tribeId || null;
     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 intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
     const intervalMonthly = [].concat(b.intervalMonthly).includes("1");
     const intervalYearly  = [].concat(b.intervalYearly).includes("1");
     const intervalYearly  = [].concat(b.intervalYearly).includes("1");
@@ -4053,6 +4212,11 @@ router
   })
   })
   .post("/calendars/update/:id", koaBody(), async (ctx) => {
   .post("/calendars/update/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     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 || {};
     const b = ctx.request.body || {};
     try {
     try {
       await calendarsModel.updateCalendarById(ctx.params.id, {
       await calendarsModel.updateCalendarById(ctx.params.id, {
@@ -4066,16 +4230,31 @@ router
   })
   })
   .post("/calendars/delete/:id", koaBody(), async (ctx) => {
   .post("/calendars/delete/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     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 (_) {}
     try { await calendarsModel.deleteCalendarById(ctx.params.id); } catch (_) {}
     ctx.redirect('/calendars');
     ctx.redirect('/calendars');
   })
   })
   .post("/calendars/join/:id", koaBody(), async (ctx) => {
   .post("/calendars/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     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 (_) {}
     try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {}
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
   })
   })
   .post("/calendars/leave/:id", koaBody(), async (ctx) => {
   .post("/calendars/leave/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     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 (_) {}
     try { await calendarsModel.leaveCalendar(ctx.params.id); } catch (_) {}
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
   })
   })
@@ -4128,12 +4307,26 @@ router
   .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => {
   .post("/calendars/delete-note/:noteId", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const calendarId = (ctx.request.body || {}).calendarId || "";
     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 (_) {}
     try { await calendarsModel.deleteNote(ctx.params.noteId); } catch (_) {}
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
   })
   })
   .post("/calendars/delete-date/:id", koaBody(), async (ctx) => {
   .post("/calendars/delete-date/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const calendarId = (ctx.request.body || {}).calendarId || "";
     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 (_) {}
     try { await calendarsModel.deleteDate(ctx.params.id, calendarId); } catch (_) {}
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
     ctx.redirect(calendarId ? `/calendars/${encodeURIComponent(calendarId)}` : '/calendars');
   })
   })
@@ -4580,6 +4773,23 @@ const middleware = [
   routes,
   routes,
 ];
 ];
 const app = http({ host, port, middleware, allowHost: config.allowHost });
 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;
 module.exports = app;
 if (config.open === true) open(url);
 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;
   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 {
 .tribe-card-members {
   border: none;
   border: none;
   border-radius: 4px;
   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;
   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 {
 .comment-submit-btn {
   width: auto;
   width: auto;
   max-width: 200px;
   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-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-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}
 .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-label { color: #2D2D2D !important; background: #FFFFFF !important; }
 .tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
 .tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
 .tribe-info-empty { color: #999 !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-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
 .tribe-members-count { color: #FF6F00 !important; }
 .tribe-members-count { color: #FF6F00 !important; }
 .tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !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-subtribe-link:hover { background: #E0E0E0 !important; }
 .tribe-parent-image { border-color: #E0E0E0 !important; }
 .tribe-parent-image { border-color: #E0E0E0 !important; }
 .tribe-parent-box { background: #FFFFFF !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-label { color: #ffa300 !important; background: #1e1f23 !important; }
 .tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
 .tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
 .tribe-info-empty { color: #9aa3b2 !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-card-members { border-color: #444 !important; background: #1e1f23 !important; }
 .tribe-members-count { color: #ffa300 !important; }
 .tribe-members-count { color: #ffa300 !important; }
 .tribe-card-actions { border-color: #444 !important; background: #1e1f23 !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-subtribe-link:hover { background: #333 !important; }
 .tribe-parent-image { border-color: #ffa300 !important; }
 .tribe-parent-image { border-color: #ffa300 !important; }
 .tribe-parent-box { background: #1e1f23 !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-label { color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-info-empty { color: #006600 !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-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
 .tribe-members-count { color: #00FF00 !important; }
 .tribe-members-count { color: #00FF00 !important; }
 .tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !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-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-image { border-color: #00FF00 !important; }
 .tribe-parent-box { background: #1A1A1A !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-label { color: #B86ADE !important; background: #3C1360 !important; }
 .tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
 .tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
 .tribe-info-empty { color: #8844aa !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-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
 .tribe-members-count { color: #FFD600 !important; }
 .tribe-members-count { color: #FFD600 !important; }
 .tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !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-subtribe-link:hover { background: #5A1A85 !important; }
 .tribe-parent-image { border-color: #B86ADE !important; }
 .tribe-parent-image { border-color: #B86ADE !important; }
 .tribe-parent-box { background: #3C1360 !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: "القبائل الأخيرة",
     tribeRecentSectionTitle: "القبائل الأخيرة",
     tribeTopSectionTitle: "القبائل الشائعة",
     tribeTopSectionTitle: "القبائل الشائعة",
     tribeviewTribeButton: "زيارة القبيلة",
     tribeviewTribeButton: "زيارة القبيلة",
+    tribeviewSubTribeButton: "زيارة القبيلة الفرعية",
+    tribeRootLabel: "الجذر",
     tribeDescription: "استكشف أو أنشئ قبائل في شبكتك.",
     tribeDescription: "استكشف أو أنشئ قبائل في شبكتك.",
     tribeFilterAll: "الكل",
     tribeFilterAll: "الكل",
     tribeFilterMine: "خاصتي",
     tribeFilterMine: "خاصتي",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "الحالة",
     tribeStatusLabel: "الحالة",
     tribeSubTribes: "القبائل الفرعية",
     tribeSubTribes: "القبائل الفرعية",
     tribeSubTribesCreate: "إنشاء قبيلة فرعية",
     tribeSubTribesCreate: "إنشاء قبيلة فرعية",
+    tribeSubTribesStrictDenied: "الوضع الصارم للقبيلة لا يسمح لك بإنشاء قبائل فرعية جديدة. يرجى الاتصال بالمسؤول.",
     tribeSubTribesEmpty: "لم يتم إنشاء قبائل فرعية بعد.",
     tribeSubTribesEmpty: "لم يتم إنشاء قبائل فرعية بعد.",
     tribeLarpCreateForbidden: "لا يمكن إنشاء قبائل L.A.R.P.",
     tribeLarpCreateForbidden: "لا يمكن إنشاء قبائل L.A.R.P.",
     tribeLarpUpdateForbidden: "لا يمكن تحديث قبائل L.A.R.P.",
     tribeLarpUpdateForbidden: "لا يمكن تحديث قبائل L.A.R.P.",
@@ -2867,6 +2870,7 @@ module.exports = {
     padNoEntries: "لا توجد مدخلات بعد.",
     padNoEntries: "لا توجد مدخلات بعد.",
     padAllSectionTitle: "جميع الوسادات",
     padAllSectionTitle: "جميع الوسادات",
     padMineSectionTitle: "وساداتي",
     padMineSectionTitle: "وساداتي",
+    padsDescription: "إدارة محرّرات النصوص التعاونية المشفّرة في شبكتك.",
     padRecentSectionTitle: "الوسادات الأخيرة",
     padRecentSectionTitle: "الوسادات الأخيرة",
     padOpenSectionTitle: "الوسادات المفتوحة",
     padOpenSectionTitle: "الوسادات المفتوحة",
     padClosedSectionTitle: "الوسادات المغلقة",
     padClosedSectionTitle: "الوسادات المغلقة",
@@ -3129,6 +3133,26 @@ module.exports = {
     tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.",
     tribeGovernanceDesc: "الحوكمة الداخلية لهذه القبيلة.",
     tribeGovernanceNoGov: "لا توجد حكومة نشطة",
     tribeGovernanceNoGov: "لا توجد حكومة نشطة",
     tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.",
     tribeGovernanceNoGovDesc: "لم تنتخب هذه القبيلة حكومة بعد.",
+    tribeGovCardTitle: "الحكومة الحالية",
+    tribeGovCycleSince: "بداية الدورة",
+    tribeGovCycleEnd: "نهاية الدورة",
+    tribeGovTimeRemaining: "الوقت المتبقي",
+    tribeGovPopulation: "السكان",
+    tribeGovMethod: "الطريقة",
+    tribeGovVotesReceived: "الأصوات المستلمة",
+    tribeGovLeader: "القائد",
+    tribeGovFilterGovernment: "الحكومة",
+    tribeGovFilterCandidatures: "الترشيحات",
+    tribeGovFilterLaws: "القوانين",
+    tribeGovCandidatureId: "الترشيح",
+    tribeGovCandidatureMethod: "الطريقة",
+    tribeGovCandidatureProposeBtn: "نشر الترشيح",
+    tribeGovRuleTitle: "عنوان القاعدة",
+    tribeGovRuleBody: "محتوى القاعدة",
+    tribeGovProposals: "الاقتراحات",
+    tribeGovRevocations: "الإلغاءات",
+    tribeGovHistorical: "السجل",
+    tribeGovRules: "القواعد",
     tribeGovernanceAlreadyPublished: "لدى هذه القبيلة ترشح مفتوح.",
     tribeGovernanceAlreadyPublished: "لدى هذه القبيلة ترشح مفتوح.",
     tribeGovernanceProposeInternal: "اقتراح ترشح داخلي",
     tribeGovernanceProposeInternal: "اقتراح ترشح داخلي",
     tribeGovernanceInternalCandidatures: "الترشيحات الداخلية",
     tribeGovernanceInternalCandidatures: "الترشيحات الداخلية",

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

@@ -1657,6 +1657,8 @@ module.exports = {
     tribeRecentSectionTitle: "Neueste Stämme",
     tribeRecentSectionTitle: "Neueste Stämme",
     tribeTopSectionTitle: "Beliebte Stämme",
     tribeTopSectionTitle: "Beliebte Stämme",
     tribeviewTribeButton: "Stamm besuchen",
     tribeviewTribeButton: "Stamm besuchen",
+    tribeviewSubTribeButton: "Unter-Stamm besuchen",
+    tribeRootLabel: "WURZEL",
     tribeDescription: "Stämme in deinem Netzwerk erkunden oder erstellen.",
     tribeDescription: "Stämme in deinem Netzwerk erkunden oder erstellen.",
     tribeFilterAll: "ALLE",
     tribeFilterAll: "ALLE",
     tribeFilterMine: "MEINE",
     tribeFilterMine: "MEINE",
@@ -1851,6 +1853,7 @@ module.exports = {
     tribeStatusLabel: "Status",
     tribeStatusLabel: "Status",
     tribeSubTribes: "UNTER-STÄMME",
     tribeSubTribes: "UNTER-STÄMME",
     tribeSubTribesCreate: "Unter-Stamm Erstellen",
     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.",
     tribeSubTribesEmpty: "Noch keine Unter-Stämme erstellt.",
     tribeLarpCreateForbidden: "L.A.R.P.-Stämme können nicht erstellt werden.",
     tribeLarpCreateForbidden: "L.A.R.P.-Stämme können nicht erstellt werden.",
     tribeLarpUpdateForbidden: "L.A.R.P.-Stämme können nicht aktualisiert werden.",
     tribeLarpUpdateForbidden: "L.A.R.P.-Stämme können nicht aktualisiert werden.",
@@ -2867,6 +2870,7 @@ module.exports = {
     padNoEntries: "Noch keine Einträge.",
     padNoEntries: "Noch keine Einträge.",
     padAllSectionTitle: "Alle Pads",
     padAllSectionTitle: "Alle Pads",
     padMineSectionTitle: "Meine Pads",
     padMineSectionTitle: "Meine Pads",
+    padsDescription: "Verwalte kollaborative verschlüsselte Texteditoren in deinem Netzwerk.",
     padRecentSectionTitle: "Aktuelle Pads",
     padRecentSectionTitle: "Aktuelle Pads",
     padOpenSectionTitle: "Offene Pads",
     padOpenSectionTitle: "Offene Pads",
     padClosedSectionTitle: "Geschlossene Pads",
     padClosedSectionTitle: "Geschlossene Pads",
@@ -3125,6 +3129,26 @@ module.exports = {
     tribeGovernanceDesc: "Interne Governance dieses Stammes.",
     tribeGovernanceDesc: "Interne Governance dieses Stammes.",
     tribeGovernanceNoGov: "Keine aktive Regierung",
     tribeGovernanceNoGov: "Keine aktive Regierung",
     tribeGovernanceNoGovDesc: "Dieser Stamm hat noch keine Regierung gewählt.",
     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.",
     tribeGovernanceAlreadyPublished: "Dieser Stamm hat bereits eine offene Kandidatur.",
     tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen",
     tribeGovernanceProposeInternal: "Interne Kandidatur vorschlagen",
     tribeGovernanceInternalCandidatures: "Interne Kandidaturen",
     tribeGovernanceInternalCandidatures: "Interne Kandidaturen",

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

@@ -1663,6 +1663,8 @@ module.exports = {
     tribeRecentSectionTitle: "Recent Tribes",
     tribeRecentSectionTitle: "Recent Tribes",
     tribeTopSectionTitle: "Popular Tribes",
     tribeTopSectionTitle: "Popular Tribes",
     tribeviewTribeButton: "Visit Tribe",
     tribeviewTribeButton: "Visit Tribe",
+    tribeviewSubTribeButton: "Visit Sub-Tribe",
+    tribeRootLabel: "ROOT",
     tribeDescription: "Explore or create tribes on your network.",
     tribeDescription: "Explore or create tribes on your network.",
     tribeFilterAll: "ALL",
     tribeFilterAll: "ALL",
     tribeFilterMine: "MINE",
     tribeFilterMine: "MINE",
@@ -1857,6 +1859,7 @@ module.exports = {
     tribeStatusLabel: "Status",
     tribeStatusLabel: "Status",
     tribeSubTribes: "SUB-TRIBES",
     tribeSubTribes: "SUB-TRIBES",
     tribeSubTribesCreate: "Create Sub-Tribe",
     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.",
     tribeSubTribesEmpty: "No sub-tribes created, yet.",
     tribeLarpCreateForbidden: "L.A.R.P. tribes cannot be created.",
     tribeLarpCreateForbidden: "L.A.R.P. tribes cannot be created.",
     tribeLarpUpdateForbidden: "L.A.R.P. tribes cannot be updated.",
     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.",
     tribeGovernanceDesc: "Internal governance for this tribe. Propose candidatures, debate rules, elect leaders.",
     tribeGovernanceNoGov: "No active government",
     tribeGovernanceNoGov: "No active government",
     tribeGovernanceNoGovDesc: "This tribe has not yet elected a government. Propose candidatures to start the process.",
     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.",
     tribeGovernanceAlreadyPublished: "This tribe already has an open candidature in the current global parliament cycle.",
     tribeGovernanceProposeInternal: "Propose internal candidature",
     tribeGovernanceProposeInternal: "Propose internal candidature",
     tribeGovernanceInternalCandidatures: "Internal candidatures",
     tribeGovernanceInternalCandidatures: "Internal candidatures",

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

@@ -1653,6 +1653,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribus Recientes",
     tribeRecentSectionTitle: "Tribus Recientes",
     tribeTopSectionTitle: "Tribus Populares",
     tribeTopSectionTitle: "Tribus Populares",
     tribeviewTribeButton: "Visitar Tribu",
     tribeviewTribeButton: "Visitar Tribu",
+    tribeviewSubTribeButton: "Visitar Sub-Tribu",
+    tribeRootLabel: "RAÍZ",
     tribeDescription: "Explora o crea tribus en tu red.",
     tribeDescription: "Explora o crea tribus en tu red.",
     tribeFilterAll: "TODOS",
     tribeFilterAll: "TODOS",
     tribeFilterMine: "MÍAS",
     tribeFilterMine: "MÍAS",
@@ -1847,6 +1849,7 @@ module.exports = {
     tribeStatusLabel: "Estado",
     tribeStatusLabel: "Estado",
     tribeSubTribes: "SUB-TRIBUS",
     tribeSubTribes: "SUB-TRIBUS",
     tribeSubTribesCreate: "Crear Sub-Tribu",
     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.",
     tribeSubTribesEmpty: "No se han creado sub-tribus, aún.",
     tribeLarpCreateForbidden: "No se pueden crear tribus L.A.R.P.",
     tribeLarpCreateForbidden: "No se pueden crear tribus L.A.R.P.",
     tribeLarpUpdateForbidden: "No se pueden actualizar 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.",
     padNoEntries: "Sin entradas aún.",
     padAllSectionTitle: "Todos los Pads",
     padAllSectionTitle: "Todos los Pads",
     padMineSectionTitle: "Mis Pads",
     padMineSectionTitle: "Mis Pads",
+    padsDescription: "Gestiona editores de texto cifrados colaborativos en tu red.",
     padRecentSectionTitle: "Pads Recientes",
     padRecentSectionTitle: "Pads Recientes",
     padOpenSectionTitle: "Pads Abiertos",
     padOpenSectionTitle: "Pads Abiertos",
     padClosedSectionTitle: "Pads Cerrados",
     padClosedSectionTitle: "Pads Cerrados",
@@ -3139,6 +3143,26 @@ module.exports = {
     tribeGovernanceDesc: "Gobierno interno de esta tribu.",
     tribeGovernanceDesc: "Gobierno interno de esta tribu.",
     tribeGovernanceNoGov: "Sin gobierno activo",
     tribeGovernanceNoGov: "Sin gobierno activo",
     tribeGovernanceNoGovDesc: "Esta tribu aún no tiene gobierno. Propón candidaturas para iniciar el proceso.",
     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.",
     tribeGovernanceAlreadyPublished: "Esta tribu ya tiene una candidatura abierta en el ciclo actual del parlamento global.",
     tribeGovernanceProposeInternal: "Proponer candidatura interna",
     tribeGovernanceProposeInternal: "Proponer candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",

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

@@ -1620,6 +1620,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeRecentSectionTitle: "Tribu Berriak",
     tribeTopSectionTitle: "Tribu Gorenak",
     tribeTopSectionTitle: "Tribu Gorenak",
     tribeviewTribeButton: "Tribua Bisitatu",
     tribeviewTribeButton: "Tribua Bisitatu",
+    tribeviewSubTribeButton: "Azpi-Tribua Bisitatu",
+    tribeRootLabel: "ERROA",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeDescription: "Aurkitu edo sortu tribuak zure sarean.",
     tribeFilterAll: "GUZTIAK",
     tribeFilterAll: "GUZTIAK",
     tribeFilterMine: "NEUREAK",
     tribeFilterMine: "NEUREAK",
@@ -1814,6 +1816,7 @@ module.exports = {
     tribeStatusLabel: "Egoera",
     tribeStatusLabel: "Egoera",
     tribeSubTribes: "AZPI-TRIBUAK",
     tribeSubTribes: "AZPI-TRIBUAK",
     tribeSubTribesCreate: "Azpi-Tribua Sortu",
     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.",
     tribeSubTribesEmpty: "Ez da azpi-triburik sortu, oraindik.",
     tribeLarpCreateForbidden: "L.A.R.P. tribuak ezin dira sortu.",
     tribeLarpCreateForbidden: "L.A.R.P. tribuak ezin dira sortu.",
     tribeLarpUpdateForbidden: "L.A.R.P. tribuak ezin dira eguneratu.",
     tribeLarpUpdateForbidden: "L.A.R.P. tribuak ezin dira eguneratu.",
@@ -2837,6 +2840,7 @@ module.exports = {
     padNoEntries: "Oraindik sarrerarik ez.",
     padNoEntries: "Oraindik sarrerarik ez.",
     padAllSectionTitle: "Pad Guztiak",
     padAllSectionTitle: "Pad Guztiak",
     padMineSectionTitle: "Nire Padak",
     padMineSectionTitle: "Nire Padak",
+    padsDescription: "Kudeatu zure sareko enkriptatutako testu-editore kolaboratiboak.",
     padRecentSectionTitle: "Azken Padak",
     padRecentSectionTitle: "Azken Padak",
     padOpenSectionTitle: "Pad Irekiak",
     padOpenSectionTitle: "Pad Irekiak",
     padClosedSectionTitle: "Pad Itxiak",
     padClosedSectionTitle: "Pad Itxiak",
@@ -3099,6 +3103,26 @@ module.exports = {
     tribeGovernanceDesc: "Tribu honen barne gobernantza.",
     tribeGovernanceDesc: "Tribu honen barne gobernantza.",
     tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
     tribeGovernanceNoGov: "Ez dago gobernu aktiborik",
     tribeGovernanceNoGovDesc: "Tribu honek ez du oraindik gobernurik aukeratu.",
     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.",
     tribeGovernanceAlreadyPublished: "Tribu honek jada hautagai ireki bat du.",
     tribeGovernanceProposeInternal: "Proposatu barne hautagaia",
     tribeGovernanceProposeInternal: "Proposatu barne hautagaia",
     tribeGovernanceInternalCandidatures: "Barne hautagaiak",
     tribeGovernanceInternalCandidatures: "Barne hautagaiak",

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

@@ -1645,6 +1645,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribus récentes",
     tribeRecentSectionTitle: "Tribus récentes",
     tribeTopSectionTitle: "Tribus populaires",
     tribeTopSectionTitle: "Tribus populaires",
     tribeviewTribeButton: "Visiter la tribu",
     tribeviewTribeButton: "Visiter la tribu",
+    tribeviewSubTribeButton: "Visiter la sous-tribu",
+    tribeRootLabel: "RACINE",
     tribeDescription: "Explorez ou créez des tribus dans votre réseau.",
     tribeDescription: "Explorez ou créez des tribus dans votre réseau.",
     tribeFilterAll: "TOUS",
     tribeFilterAll: "TOUS",
     tribeFilterMine: "MIENS",
     tribeFilterMine: "MIENS",
@@ -1839,6 +1841,7 @@ module.exports = {
     tribeStatusLabel: "Statut",
     tribeStatusLabel: "Statut",
     tribeSubTribes: "SOUS-TRIBUS",
     tribeSubTribes: "SOUS-TRIBUS",
     tribeSubTribesCreate: "Créer Sous-Tribu",
     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.",
     tribeSubTribesEmpty: "Aucune sous-tribu créée, pour l'instant.",
     tribeLarpCreateForbidden: "Les tribus L.A.R.P. ne peuvent pas être créées.",
     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.",
     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.",
     padNoEntries: "Aucune entrée pour l'instant.",
     padAllSectionTitle: "Tous les Pads",
     padAllSectionTitle: "Tous les Pads",
     padMineSectionTitle: "Mes Pads",
     padMineSectionTitle: "Mes Pads",
+    padsDescription: "Gérez des éditeurs de texte chiffrés collaboratifs dans votre réseau.",
     padRecentSectionTitle: "Pads Récents",
     padRecentSectionTitle: "Pads Récents",
     padOpenSectionTitle: "Pads Ouverts",
     padOpenSectionTitle: "Pads Ouverts",
     padClosedSectionTitle: "Pads Fermés",
     padClosedSectionTitle: "Pads Fermés",
@@ -3127,6 +3131,26 @@ module.exports = {
     tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
     tribeGovernanceDesc: "Gouvernance interne de cette tribu.",
     tribeGovernanceNoGov: "Pas de gouvernement actif",
     tribeGovernanceNoGov: "Pas de gouvernement actif",
     tribeGovernanceNoGovDesc: "Cette tribu n'a pas encore de gouvernement.",
     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.",
     tribeGovernanceAlreadyPublished: "Cette tribu a déjà une candidature ouverte dans le cycle actuel.",
     tribeGovernanceProposeInternal: "Proposer une candidature interne",
     tribeGovernanceProposeInternal: "Proposer une candidature interne",
     tribeGovernanceInternalCandidatures: "Candidatures internes",
     tribeGovernanceInternalCandidatures: "Candidatures internes",

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

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

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

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribù recenti",
     tribeRecentSectionTitle: "Tribù recenti",
     tribeTopSectionTitle: "Tribù popolari",
     tribeTopSectionTitle: "Tribù popolari",
     tribeviewTribeButton: "Visita tribù",
     tribeviewTribeButton: "Visita tribù",
+    tribeviewSubTribeButton: "Visita Sotto-Tribù",
+    tribeRootLabel: "RADICE",
     tribeDescription: "Esplora o crea tribù nella tua rete.",
     tribeDescription: "Esplora o crea tribù nella tua rete.",
     tribeFilterAll: "TUTTE",
     tribeFilterAll: "TUTTE",
     tribeFilterMine: "MIE",
     tribeFilterMine: "MIE",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "Stato",
     tribeStatusLabel: "Stato",
     tribeSubTribes: "SOTTO-TRIBÙ",
     tribeSubTribes: "SOTTO-TRIBÙ",
     tribeSubTribesCreate: "Crea 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.",
     tribeSubTribesEmpty: "Nessuna sotto-tribù creata.",
     tribeLarpCreateForbidden: "Le tribù L.A.R.P. non possono essere create.",
     tribeLarpCreateForbidden: "Le tribù L.A.R.P. non possono essere create.",
     tribeLarpUpdateForbidden: "Le tribù L.A.R.P. non possono essere aggiornate.",
     tribeLarpUpdateForbidden: "Le tribù L.A.R.P. non possono essere aggiornate.",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "Nessuna voce ancora.",
     padNoEntries: "Nessuna voce ancora.",
     padAllSectionTitle: "Tutti i Pad",
     padAllSectionTitle: "Tutti i Pad",
     padMineSectionTitle: "I Miei Pad",
     padMineSectionTitle: "I Miei Pad",
+    padsDescription: "Gestisci editor di testo collaborativi cifrati nella tua rete.",
     padRecentSectionTitle: "Pad Recenti",
     padRecentSectionTitle: "Pad Recenti",
     padOpenSectionTitle: "Pad Aperti",
     padOpenSectionTitle: "Pad Aperti",
     padClosedSectionTitle: "Pad Chiusi",
     padClosedSectionTitle: "Pad Chiusi",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "Governance interna di questa tribù.",
     tribeGovernanceDesc: "Governance interna di questa tribù.",
     tribeGovernanceNoGov: "Nessun governo attivo",
     tribeGovernanceNoGov: "Nessun governo attivo",
     tribeGovernanceNoGovDesc: "Questa tribù non ha ancora eletto un governo.",
     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.",
     tribeGovernanceAlreadyPublished: "Questa tribù ha già una candidatura aperta.",
     tribeGovernanceProposeInternal: "Proponi candidatura interna",
     tribeGovernanceProposeInternal: "Proponi candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidature interne",
     tribeGovernanceInternalCandidatures: "Candidature interne",

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

@@ -1658,6 +1658,8 @@ module.exports = {
     tribeRecentSectionTitle: "Tribos recentes",
     tribeRecentSectionTitle: "Tribos recentes",
     tribeTopSectionTitle: "Tribos populares",
     tribeTopSectionTitle: "Tribos populares",
     tribeviewTribeButton: "Visitar tribo",
     tribeviewTribeButton: "Visitar tribo",
+    tribeviewSubTribeButton: "Visitar sub-tribo",
+    tribeRootLabel: "RAIZ",
     tribeDescription: "Explora ou cria tribos na tua rede.",
     tribeDescription: "Explora ou cria tribos na tua rede.",
     tribeFilterAll: "TODOS",
     tribeFilterAll: "TODOS",
     tribeFilterMine: "MEUS",
     tribeFilterMine: "MEUS",
@@ -1852,6 +1854,7 @@ module.exports = {
     tribeStatusLabel: "Estado",
     tribeStatusLabel: "Estado",
     tribeSubTribes: "SUB-TRIBOS",
     tribeSubTribes: "SUB-TRIBOS",
     tribeSubTribesCreate: "Criar Sub-Tribo",
     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.",
     tribeSubTribesEmpty: "Ainda sem sub-tribos criadas.",
     tribeLarpCreateForbidden: "Tribos L.A.R.P. não podem ser criadas.",
     tribeLarpCreateForbidden: "Tribos L.A.R.P. não podem ser criadas.",
     tribeLarpUpdateForbidden: "Tribos L.A.R.P. não podem ser atualizadas.",
     tribeLarpUpdateForbidden: "Tribos L.A.R.P. não podem ser atualizadas.",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "Sem entradas ainda.",
     padNoEntries: "Sem entradas ainda.",
     padAllSectionTitle: "Todos os Pads",
     padAllSectionTitle: "Todos os Pads",
     padMineSectionTitle: "Os Meus Pads",
     padMineSectionTitle: "Os Meus Pads",
+    padsDescription: "Gere editores de texto colaborativos cifrados na tua rede.",
     padRecentSectionTitle: "Pads Recentes",
     padRecentSectionTitle: "Pads Recentes",
     padOpenSectionTitle: "Pads Abertos",
     padOpenSectionTitle: "Pads Abertos",
     padClosedSectionTitle: "Pads Fechados",
     padClosedSectionTitle: "Pads Fechados",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "Governança interna desta tribo.",
     tribeGovernanceDesc: "Governança interna desta tribo.",
     tribeGovernanceNoGov: "Sem governo ativo",
     tribeGovernanceNoGov: "Sem governo ativo",
     tribeGovernanceNoGovDesc: "Esta tribo ainda não elegeu governo.",
     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.",
     tribeGovernanceAlreadyPublished: "Esta tribo já tem candidatura aberta.",
     tribeGovernanceProposeInternal: "Propor candidatura interna",
     tribeGovernanceProposeInternal: "Propor candidatura interna",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",
     tribeGovernanceInternalCandidatures: "Candidaturas internas",

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

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

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

@@ -1659,6 +1659,8 @@ module.exports = {
     tribeRecentSectionTitle: "最近的部落",
     tribeRecentSectionTitle: "最近的部落",
     tribeTopSectionTitle: "热门部落",
     tribeTopSectionTitle: "热门部落",
     tribeviewTribeButton: "访问部落",
     tribeviewTribeButton: "访问部落",
+    tribeviewSubTribeButton: "访问子部落",
+    tribeRootLabel: "根",
     tribeDescription: "探索或创建你网络中的部落。",
     tribeDescription: "探索或创建你网络中的部落。",
     tribeFilterAll: "全部",
     tribeFilterAll: "全部",
     tribeFilterMine: "我的",
     tribeFilterMine: "我的",
@@ -1853,6 +1855,7 @@ module.exports = {
     tribeStatusLabel: "状态",
     tribeStatusLabel: "状态",
     tribeSubTribes: "子部落",
     tribeSubTribes: "子部落",
     tribeSubTribesCreate: "创建子部落",
     tribeSubTribesCreate: "创建子部落",
+    tribeSubTribesStrictDenied: "部落的严格模式不允许您创建新的子部落。请联系管理员。",
     tribeSubTribesEmpty: "还没有创建子部落。",
     tribeSubTribesEmpty: "还没有创建子部落。",
     tribeLarpCreateForbidden: "无法创建 L.A.R.P. 部落。",
     tribeLarpCreateForbidden: "无法创建 L.A.R.P. 部落。",
     tribeLarpUpdateForbidden: "无法更新 L.A.R.P. 部落。",
     tribeLarpUpdateForbidden: "无法更新 L.A.R.P. 部落。",
@@ -2868,6 +2871,7 @@ module.exports = {
     padNoEntries: "暂无内容。",
     padNoEntries: "暂无内容。",
     padAllSectionTitle: "全部协作板",
     padAllSectionTitle: "全部协作板",
     padMineSectionTitle: "我的协作板",
     padMineSectionTitle: "我的协作板",
+    padsDescription: "在您的网络中管理协作加密的文本编辑器。",
     padRecentSectionTitle: "最近协作板",
     padRecentSectionTitle: "最近协作板",
     padOpenSectionTitle: "开放协作板",
     padOpenSectionTitle: "开放协作板",
     padClosedSectionTitle: "关闭协作板",
     padClosedSectionTitle: "关闭协作板",
@@ -3130,6 +3134,26 @@ module.exports = {
     tribeGovernanceDesc: "此部落的内部治理。",
     tribeGovernanceDesc: "此部落的内部治理。",
     tribeGovernanceNoGov: "无活跃政府",
     tribeGovernanceNoGov: "无活跃政府",
     tribeGovernanceNoGovDesc: "此部落尚未选举政府。",
     tribeGovernanceNoGovDesc: "此部落尚未选举政府。",
+    tribeGovCardTitle: "当前政府",
+    tribeGovCycleSince: "周期开始",
+    tribeGovCycleEnd: "周期结束",
+    tribeGovTimeRemaining: "剩余时间",
+    tribeGovPopulation: "人口",
+    tribeGovMethod: "方法",
+    tribeGovVotesReceived: "收到的投票",
+    tribeGovLeader: "领导者",
+    tribeGovFilterGovernment: "政府",
+    tribeGovFilterCandidatures: "候选",
+    tribeGovFilterLaws: "法律",
+    tribeGovCandidatureId: "候选",
+    tribeGovCandidatureMethod: "方法",
+    tribeGovCandidatureProposeBtn: "发布候选",
+    tribeGovRuleTitle: "规则标题",
+    tribeGovRuleBody: "规则内容",
+    tribeGovProposals: "提案",
+    tribeGovRevocations: "撤销",
+    tribeGovHistorical: "历史",
+    tribeGovRules: "规则",
     tribeGovernanceAlreadyPublished: "此部落在当前周期已有候选人。",
     tribeGovernanceAlreadyPublished: "此部落在当前周期已有候选人。",
     tribeGovernanceProposeInternal: "提议内部候选人",
     tribeGovernanceProposeInternal: "提议内部候选人",
     tribeGovernanceInternalCandidatures: "内部候选人",
     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())
   return out.sort((a, b) => a.getTime() - b.getTime())
 }
 }
 
 
-module.exports = ({ cooler, pmModel }) => {
+module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
   let ssb
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return 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)))
       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 buildIndex = (messages) => {
     const tomb = new Set()
     const tomb = new Set()
     const nodes = new Map()
     const nodes = new Map()
     const parent = new Map()
     const parent = new Map()
     const child = new Map()
     const child = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
 
     for (const m of messages) {
     for (const m of messages) {
       const k = m.key
       const k = m.key
       const v = m.value || {}
       const v = m.value || {}
       const c = v.content
       const c = v.content
       if (!c) continue
       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") {
       if (c.type === "calendar") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
         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) }
         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 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 }
     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 buildCalendar = (node, rootId) => {
     const c = node.c || {}
     const c = node.c || {}
     if (c.type !== "calendar") return null
     if (c.type !== "calendar") return null
+    const undec = c.encryptedPayload && c._decrypted === false
     return {
     return {
       key: node.key,
       key: node.key,
       rootId,
       rootId,
-      title: safeText(c.title),
+      title: undec ? "" : safeText(c.title),
       status: c.status || "OPEN",
       status: c.status || "OPEN",
-      deadline: c.deadline || "",
+      deadline: undec ? "" : (c.deadline || ""),
       tags: Array.isArray(c.tags) ? c.tags : [],
       tags: Array.isArray(c.tags) ? c.tags : [],
       author: c.author || node.author,
       author: c.author || node.author,
       participants: Array.isArray(c.participants) ? c.participants : [],
       participants: Array.isArray(c.participants) ? c.participants : [],
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
-      tribeId: c.tribeId || null
+      tribeId: c.tribeId || null,
+      encrypted: !!undec
     }
     }
   }
   }
 
 
+
   const isClosed = (calendar) => {
   const isClosed = (calendar) => {
     if (calendar.status === "CLOSED") return true
     if (calendar.status === "CLOSED") return true
     if (!calendar.deadline) return false
     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 (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")
       if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
 
 
-      const content = {
+      let content = {
         type: "calendar",
         type: "calendar",
         title: safeText(title),
         title: safeText(title),
         status: validStatus,
         status: validStatus,
@@ -138,6 +155,7 @@ module.exports = ({ cooler, pmModel }) => {
         updatedAt: now,
         updatedAt: now,
         ...(tribeId ? { tribeId } : {})
         ...(tribeId ? { tribeId } : {})
       }
       }
+      content = await encryptIfTribe(content)
 
 
       const calMsg = await new Promise((resolve, reject) => {
       const calMsg = await new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
@@ -148,30 +166,36 @@ module.exports = ({ cooler, pmModel }) => {
 
 
       const allDateMsgs = []
       const allDateMsgs = []
       for (const d of dates) {
       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) => {
         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)
         allDateMsgs.push(dateMsg)
       }
       }
 
 
       if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
       if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
         for (const dateMsg of allDateMsgs) {
         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) => {
           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 tipId = await this.resolveCurrentId(id)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       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) {
     async deleteCalendarById(id) {
       const tipId = await this.resolveCurrentId(id)
       const tipId = await this.resolveCurrentId(id)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       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) {
     async joinCalendar(calendarId) {
       const tipId = await this.resolveCurrentId(calendarId)
       const tipId = await this.resolveCurrentId(calendarId)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       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) {
     async leaveCalendar(calendarId) {
       const tipId = await this.resolveCurrentId(calendarId)
       const tipId = await this.resolveCurrentId(calendarId)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       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) {
     async getCalendarById(id) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
       const idx = buildIndex(messages)
+      await decryptIndexNodes(idx)
       let tip = id
       let tip = id
       while (idx.child.has(tip)) tip = idx.child.get(tip)
       while (idx.child.has(tip)) tip = idx.child.get(tip)
       if (idx.tomb.has(tip)) return null
       if (idx.tomb.has(tip)) return null
@@ -285,6 +331,7 @@ module.exports = ({ cooler, pmModel }) => {
       const uid = viewerId || ssbClient.id
       const uid = viewerId || ssbClient.id
       const messages = await readAll(ssbClient)
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
       const idx = buildIndex(messages)
+      await decryptIndexNodes(idx)
       const items = []
       const items = []
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
         if (idx.tomb.has(tipId)) continue
         if (idx.tomb.has(tipId)) continue
@@ -319,15 +366,18 @@ module.exports = ({ cooler, pmModel }) => {
       const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
       const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
       const allMsgs = []
       const allMsgs = []
       for (const d of dates) {
       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) => {
         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)
         allMsgs.push(msg)
       }
       }
@@ -338,10 +388,15 @@ module.exports = ({ cooler, pmModel }) => {
       const rootId = await this.resolveRootId(calendarId)
       const rootId = await this.resolveRootId(calendarId)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       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()
       const tombstoned = new Set()
       for (const m of messages) {
       for (const m of messages) {
         const c = (m.value || {}).content
         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 = []
       const dates = []
       for (const m of messages) {
       for (const m of messages) {
@@ -350,13 +405,19 @@ module.exports = ({ cooler, pmModel }) => {
         const c = v.content
         const c = v.content
         if (!c || c.type !== "calendarDate") continue
         if (!c || c.type !== "calendarDate") continue
         if (c.calendarId !== rootId) 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({
         dates.push({
           key: m.key,
           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))
       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)
       const cal = await this.getCalendarById(rootId)
       if (!cal) throw new Error("Calendar not found")
       if (!cal) throw new Error("Calendar not found")
       const messages = await readAll(ssbClient)
       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()
       const tombstoned = new Set()
       for (const m of messages) {
       for (const m of messages) {
         const c = (m.value || {}).content
         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
       let dateAuthor = null
       for (const m of messages) {
       for (const m of messages) {
@@ -381,7 +447,12 @@ module.exports = ({ cooler, pmModel }) => {
         const c = (m.value || {}).content
         const c = (m.value || {}).content
         if (!c || c.type !== "calendarDate") continue
         if (!c || c.type !== "calendarDate") continue
         if (tombstoned.has(m.key)) break
         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
         break
       }
       }
       if (!dateAuthor) throw new Error("Date not found")
       if (!dateAuthor) throw new Error("Date not found")
@@ -407,27 +478,30 @@ module.exports = ({ cooler, pmModel }) => {
       const cal = await this.getCalendarById(rootId)
       const cal = await this.getCalendarById(rootId)
       if (!cal) throw new Error("Calendar not found")
       if (!cal) throw new Error("Calendar not found")
       if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
       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) => {
       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) {
     async deleteNote(noteId) {
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       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) => {
       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 rootId = await this.resolveRootId(calendarId)
       const ssbClient = await openSsb()
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
       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()
       const tombstoned = new Set()
       for (const m of messages) {
       for (const m of messages) {
         const c = (m.value || {}).content
         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 = []
       const notes = []
       for (const m of messages) {
       for (const m of messages) {
@@ -447,13 +526,19 @@ module.exports = ({ cooler, pmModel }) => {
         if (!c || c.type !== "calendarNote") continue
         if (!c || c.type !== "calendarNote") continue
         if (tombstoned.has(m.key)) continue
         if (tombstoned.has(m.key)) continue
         if (c.calendarId !== rootId || c.dateId !== dateId) 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({
         notes.push({
           key: m.key,
           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))
       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}`)
         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()
       const tombstoned = new Set()
       for (const m of messages) {
       for (const m of messages) {
         const c = (m.value || {}).content
         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()
       const dueByCalendar = new Map()
@@ -485,9 +575,15 @@ module.exports = ({ cooler, pmModel }) => {
         const v = m.value || {}
         const v = m.value || {}
         const c = v.content
         const c = v.content
         if (!c || c.type !== "calendarDate") continue
         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
         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) || []
         const list = dueByCalendar.get(c.calendarId) || []
         list.push(entry)
         list.push(entry)
         dueByCalendar.set(c.calendarId, list)
         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 parent = new Map()
     const child = new Map()
     const child = new Map()
     const msgNodes = new Map()
     const msgNodes = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
 
     for (const m of messages) {
     for (const m of messages) {
       const k = m.key
       const k = m.key
       const v = m.value || {}
       const v = m.value || {}
       const c = v.content
       const c = v.content
       if (!c) continue
       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") {
       if (c.type === "chat") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
         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) }
         if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
       } else if (c.type === "chatMessage") {
       } else if (c.type === "chatMessage") {
         msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
         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 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 }
     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"]);
 const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
   let ssb;
 
 
   const openSsb = async () => {
   const openSsb = async () => {
@@ -21,6 +21,12 @@ module.exports = ({ cooler }) => {
     return ssb;
     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) =>
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
     new Promise((resolve, reject) => {
       pull(
       pull(
@@ -30,8 +36,8 @@ module.exports = ({ cooler }) => {
     });
     });
 
 
   const getMsg = async (ssbClient, key) =>
   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) => {
   const buildIndex = (messages) => {
@@ -40,6 +46,9 @@ module.exports = ({ cooler }) => {
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
     const markers = new Map();
     const markers = new Map();
+    const rawMarkers = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
 
     for (const m of messages) {
     for (const m of messages) {
       const k = m.key;
       const k = m.key;
@@ -48,22 +57,20 @@ module.exports = ({ cooler }) => {
       if (!c) continue;
       if (!c) continue;
 
 
       if (c.type === "tombstone" && c.target) {
       if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
+        tombRequests.push({ target: c.target, author: v.author });
         continue;
         continue;
       }
       }
 
 
       if (c.type === "mapMarker") {
       if (c.type === "mapMarker") {
         const mapId = c.mapId;
         const mapId = c.mapId;
         if (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,
             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;
         continue;
@@ -73,6 +80,7 @@ module.exports = ({ cooler }) => {
 
 
       const ts = v.timestamp || m.timestamp || 0;
       const ts = v.timestamp || m.timestamp || 0;
       nodes.set(k, { key: k, ts, c });
       nodes.set(k, { key: k, ts, c });
+      authorByKey.set(k, v.author);
 
 
       if (c.replaces) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -92,6 +100,11 @@ module.exports = ({ cooler }) => {
       return cur;
       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();
     const roots = new Set();
     for (const id of nodes.keys()) roots.add(rootOf(id));
     for (const id of nodes.keys()) roots.add(rootOf(id));
 
 
@@ -101,24 +114,51 @@ module.exports = ({ cooler }) => {
     const forward = new Map();
     const forward = new Map();
     for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
     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 buildMap = (node, rootId, viewerId, markerList = []) => {
     const c = node.c || {};
     const c = node.c || {};
+    const undec = c.encryptedPayload && c._decrypted === false;
     return {
     return {
       key: node.key,
       key: node.key,
       rootId,
       rootId,
-      title: c.title || "",
+      title: undec ? "" : (c.title || ""),
       lat: parseFloat(c.lat) || 0,
       lat: parseFloat(c.lat) || 0,
       lng: parseFloat(c.lng) || 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",
       mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
       tags: safeArr(c.tags),
       tags: safeArr(c.tags),
       author: c.author,
       author: c.author,
       tribeId: c.tribeId || null,
       tribeId: c.tribeId || null,
+      encrypted: !!undec,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
       markers: markerList.filter((mk) => !mk.tombstoned)
       markers: markerList.filter((mk) => !mk.tombstoned)
@@ -159,7 +199,7 @@ module.exports = ({ cooler }) => {
       const now = new Date().toISOString();
       const now = new Date().toISOString();
       const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
       const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
 
 
-      const content = {
+      let content = {
         type: "map",
         type: "map",
         title: title || "",
         title: title || "",
         lat: parseFloat(lat) || 0,
         lat: parseFloat(lat) || 0,
@@ -175,6 +215,8 @@ module.exports = ({ cooler }) => {
         updatedAt: now
         updatedAt: now
       };
       };
 
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
       });
@@ -187,32 +229,39 @@ module.exports = ({ cooler }) => {
       const oldMsg = await getMsg(ssbClient, tipId);
       const oldMsg = await getMsg(ssbClient, tipId);
 
 
       if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found");
       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 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,
         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,
         mapType: mType,
         tags,
         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
         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) {
     async deleteMapById(id) {
@@ -222,7 +271,8 @@ module.exports = ({ cooler }) => {
       const msg = await getMsg(ssbClient, tipId);
       const msg = await getMsg(ssbClient, tipId);
 
 
       if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
       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 };
       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);
       const node = idx.nodes.get(tipId);
       if (!node) throw new Error("Map not found");
       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 === "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 now = new Date().toISOString();
-      const content = {
+      let content = {
         type: "mapMarker",
         type: "mapMarker",
         mapId: tipId,
         mapId: tipId,
         lat: parseFloat(lat) || 0,
         lat: parseFloat(lat) || 0,
         lng: parseFloat(lng) || 0,
         lng: parseFloat(lng) || 0,
         label: label || "",
         label: label || "",
         author: userId,
         author: userId,
-        createdAt: now
+        createdAt: now,
+        ...(node.c.tribeId ? { tribeId: node.c.tribeId } : {})
       };
       };
       if (image) content.image = image;
       if (image) content.image = image;
 
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
       });
@@ -276,6 +332,8 @@ module.exports = ({ cooler }) => {
 
 
       const messages = await getAllMessages(ssbClient);
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
+      await expandMarkers(idx);
 
 
       const items = [];
       const items = [];
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
@@ -312,6 +370,8 @@ module.exports = ({ cooler }) => {
 
 
       const messages = await getAllMessages(ssbClient);
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
+      await expandMarkers(idx);
 
 
       let tip = id;
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -324,8 +384,13 @@ module.exports = ({ cooler }) => {
       if (!node) {
       if (!node) {
         const msg = await getMsg(ssbClient, tip);
         const msg = await getMsg(ssbClient, tip);
         if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
         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)));
         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)));
       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 nodes = new Map()
     const parent = new Map()
     const parent = new Map()
     const child = new Map()
     const child = new Map()
+    const authorByKey = new Map()
+    const tombRequests = []
 
 
     for (const m of messages) {
     for (const m of messages) {
       const k = m.key
       const k = m.key
       const v = m.value || {}
       const v = m.value || {}
       const c = v.content
       const c = v.content
       if (!c) continue
       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") {
       if (c.type === "pad") {
         nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
         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) }
         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 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 }
     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';
     const isTribe = term.powerType === 'tribe';
     let members = 1;
     let members = 1;
     if (isTribe && term.powerId) {
     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;
       members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
     }
     }
     const pol = await summarizePoliciesForTerm({ ...term });
     const pol = await summarizePoliciesForTerm({ ...term });
@@ -1067,7 +1070,7 @@ module.exports = ({ cooler, services = {} }) => {
     if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
     if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
 
 
     if (latestAny && isExpiredTerm(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();
     const open = await listCandidaturesOpen();
@@ -1183,7 +1186,10 @@ module.exports = ({ cooler, services = {} }) => {
     if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
     if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
     if (term.powerType === 'inhabitant') return term.powerId === userId;
     if (term.powerType === 'inhabitant') return term.powerId === userId;
     if (term.powerType === 'tribe') {
     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);
       const members = ensureArray(tribe?.members);
       return members.includes(userId);
       return members.includes(userId);
     }
     }
@@ -1201,6 +1207,13 @@ module.exports = ({ cooler, services = {} }) => {
   };
   };
 
 
   const tribeListByType = async (type, tribeId) => {
   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 msgs = await tribeReadLog();
     const tomb = new Set();
     const tomb = new Set();
     const replaced = new Set();
     const replaced = new Set();
@@ -1209,7 +1222,7 @@ module.exports = ({ cooler, services = {} }) => {
       const c = m.value?.content; if (!c) continue;
       const c = m.value?.content; if (!c) continue;
       if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
       if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
       if (c.type !== type) continue;
       if (c.type !== type) continue;
-      if (c.tribeId !== tribeId) continue;
+      if (!tribeIdSet.has(c.tribeId)) continue;
       if (c.replaces) replaced.add(c.replaces);
       if (c.replaces) replaced.add(c.replaces);
       items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 });
       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;
     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 tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId);
   const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
   const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
 
 
@@ -1285,11 +1369,18 @@ module.exports = ({ cooler, services = {} }) => {
   };
   };
 
 
   const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => {
   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 msgs = await tribeReadLog();
     const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
     const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
     return msgs.some(m => {
     return msgs.some(m => {
       const c = m.value?.content; if (!c) return false;
       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,
       voteTribeCandidature: tribeVoteCandidature,
       publishTribeRule: tribePublishRule,
       publishTribeRule: tribePublishRule,
       deleteTribeRule: tribeDeleteRule,
       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 = {}) =>
 const voteSum = (opinions = {}) =>
   Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
   Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
 
 
-module.exports = ({ cooler }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb;
   let ssb;
 
 
   const openSsb = async () => {
   const openSsb = async () => {
@@ -31,14 +31,20 @@ module.exports = ({ cooler }) => {
     return ssb;
     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) =>
   const getAllMessages = async (ssbClient) =>
     new Promise((resolve, reject) => {
     new Promise((resolve, reject) => {
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
     });
     });
 
 
   const getMsg = async (ssbClient, key) =>
   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) => {
   const buildIndex = (messages) => {
@@ -46,6 +52,8 @@ module.exports = ({ cooler }) => {
     const nodes = new Map();
     const nodes = new Map();
     const parent = new Map();
     const parent = new Map();
     const child = new Map();
     const child = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
 
     for (const m of messages) {
     for (const m of messages) {
       const k = m.key;
       const k = m.key;
@@ -54,7 +62,7 @@ module.exports = ({ cooler }) => {
       if (!c) continue;
       if (!c) continue;
 
 
       if (c.type === "tombstone" && c.target) {
       if (c.type === "tombstone" && c.target) {
-        tomb.add(c.target);
+        tombRequests.push({ target: c.target, author: v.author });
         continue;
         continue;
       }
       }
 
 
@@ -62,6 +70,7 @@ module.exports = ({ cooler }) => {
 
 
       const ts = v.timestamp || m.timestamp || 0;
       const ts = v.timestamp || m.timestamp || 0;
       nodes.set(k, { key: k, ts, c });
       nodes.set(k, { key: k, ts, c });
+      authorByKey.set(k, v.author);
 
 
       if (c.replaces) {
       if (c.replaces) {
         parent.set(k, c.replaces);
         parent.set(k, c.replaces);
@@ -81,6 +90,11 @@ module.exports = ({ cooler }) => {
       return cur;
       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();
     const roots = new Set();
     for (const id of nodes.keys()) roots.add(rootOf(id));
     for (const id of nodes.keys()) roots.add(rootOf(id));
 
 
@@ -95,24 +109,28 @@ module.exports = ({ cooler }) => {
 
 
   const buildTorrent = (node, rootId, viewerId) => {
   const buildTorrent = (node, rootId, viewerId) => {
     const c = node.c || {};
     const c = node.c || {};
+    const undec = c.encryptedPayload && c._decrypted === false;
     const voters = safeArr(c.opinions_inhabitants);
     const voters = safeArr(c.opinions_inhabitants);
     return {
     return {
       key: node.key,
       key: node.key,
       rootId,
       rootId,
-      url: c.url,
+      url: undec ? "" : c.url,
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       createdAt: c.createdAt || new Date(node.ts).toISOString(),
       updatedAt: c.updatedAt || null,
       updatedAt: c.updatedAt || null,
       tags: safeArr(c.tags),
       tags: safeArr(c.tags),
       author: c.author,
       author: c.author,
-      title: c.title || "",
-      description: c.description || "",
+      title: undec ? "" : (c.title || ""),
+      description: undec ? "" : (c.description || ""),
       size: c.size || 0,
       size: c.size || 0,
       opinions: c.opinions || {},
       opinions: c.opinions || {},
       opinions_inhabitants: voters,
       opinions_inhabitants: voters,
-      hasVoted: viewerId ? voters.includes(viewerId) : false
+      hasVoted: viewerId ? voters.includes(viewerId) : false,
+      tribeId: c.tribeId || null,
+      encrypted: !!undec
     };
     };
   };
   };
 
 
+
   return {
   return {
     type: "torrent",
     type: "torrent",
 
 
@@ -141,13 +159,13 @@ module.exports = ({ cooler }) => {
       return root;
       return root;
     },
     },
 
 
-    async createTorrent(blobMarkdown, tagsRaw, title, description, size) {
+    async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) {
       const ssbClient = await openSsb();
       const ssbClient = await openSsb();
       const blobId = parseBlobId(blobMarkdown);
       const blobId = parseBlobId(blobMarkdown);
       const tags = normalizeTags(tagsRaw) || [];
       const tags = normalizeTags(tagsRaw) || [];
       const now = new Date().toISOString();
       const now = new Date().toISOString();
 
 
-      const content = {
+      let content = {
         type: "torrent",
         type: "torrent",
         url: blobId,
         url: blobId,
         createdAt: now,
         createdAt: now,
@@ -158,9 +176,12 @@ module.exports = ({ cooler }) => {
         description: description || "",
         description: description || "",
         size: Number(size) || 0,
         size: Number(size) || 0,
         opinions: {},
         opinions: {},
-        opinions_inhabitants: []
+        opinions_inhabitants: [],
+        ...(tribeId ? { tribeId } : {})
       };
       };
 
 
+      content = await encryptIfTribe(content);
+
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
         ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
       });
       });
@@ -173,30 +194,39 @@ module.exports = ({ cooler }) => {
       const oldMsg = await getMsg(ssbClient, tipId);
       const oldMsg = await getMsg(ssbClient, tipId);
 
 
       if (!oldMsg || oldMsg.content?.type !== "torrent") throw new Error("Torrent not found");
       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 blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
       const now = new Date().toISOString();
       const now = new Date().toISOString();
 
 
-      const updated = {
-        ...oldMsg.content,
+      let updated = {
+        type: "torrent",
         replaces: tipId,
         replaces: tipId,
-        url: blobId || oldMsg.content.url,
+        url: blobId || oldDec.url,
         tags,
         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
         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) {
     async deleteTorrentById(id) {
@@ -206,7 +236,8 @@ module.exports = ({ cooler }) => {
       const msg = await getMsg(ssbClient, tipId);
       const msg = await getMsg(ssbClient, tipId);
 
 
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
       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 };
       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 messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
 
 
       const items = [];
       const items = [];
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
@@ -270,6 +302,7 @@ module.exports = ({ cooler }) => {
       const viewer = viewerId || ssbClient.id;
       const viewer = viewerId || ssbClient.id;
       const messages = await getAllMessages(ssbClient);
       const messages = await getAllMessages(ssbClient);
       const idx = buildIndex(messages);
       const idx = buildIndex(messages);
+      await decryptIndexNodes(idx);
 
 
       let tip = id;
       let tip = id;
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
       while (idx.forward.has(tip)) tip = idx.forward.get(tip);
@@ -283,7 +316,12 @@ module.exports = ({ cooler }) => {
 
 
       const msg = await getMsg(ssbClient, tip);
       const msg = await getMsg(ssbClient, tip);
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
       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) {
     async createOpinion(id, category) {
@@ -297,27 +335,39 @@ module.exports = ({ cooler }) => {
 
 
       if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
       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");
       if (voters.includes(userId)) throw new Error("Already voted");
 
 
       const now = new Date().toISOString();
       const now = new Date().toISOString();
-      const updated = {
-        ...msg.content,
+      let updated = {
+        type: "torrent",
         replaces: tipId,
         replaces: tipId,
+        url: oldDec.url,
+        tags: oldDec.tags || [],
+        title: oldDec.title || "",
+        description: oldDec.description || "",
+        size: oldDec.size || 0,
         opinions: {
         opinions: {
-          ...msg.content.opinions,
-          [category]: (msg.content.opinions?.[category] || 0) + 1
+          ...(oldDec.opinions || {}),
+          [category]: ((oldDec.opinions || {})[category] || 0) + 1
         },
         },
         opinions_inhabitants: voters.concat(userId),
         opinions_inhabitants: voters.concat(userId),
+        author: oldDec.author,
+        ...(msg.content.tribeId ? { tribeId: msg.content.tribeId } : {}),
+        createdAt: oldDec.createdAt,
         updatedAt: now
         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',
   'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
   'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
   'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
   'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
   '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';
 const INVITE_SALT = 'SolarNET.HuB';
 
 
 module.exports = (configPath) => {
 module.exports = (configPath) => {
@@ -26,7 +33,9 @@ module.exports = (configPath) => {
   };
   };
 
 
   const saveKeyring = () => {
   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');
   const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
@@ -89,6 +98,23 @@ module.exports = (configPath) => {
     return decryptWithKey(encryptedKey, derived.toString('hex'));
     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) => {
   const encryptChain = (plaintext, keyChain) => {
     let data = plaintext;
     let data = plaintext;
     for (const keyHex of keyChain) {
     for (const keyHex of keyChain) {
@@ -106,18 +132,25 @@ module.exports = (configPath) => {
     return data;
     return data;
   };
   };
 
 
-  const encryptContent = (content, keyChain) => {
+  const encryptContent = (content, keyChain, customFields) => {
     const payload = {};
     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 plaintext = JSON.stringify(payload);
     const encryptedPayload = encryptChain(plaintext, keyChain);
     const encryptedPayload = encryptChain(plaintext, keyChain);
     const result = {};
     const result = {};
     for (const [k, v] of Object.entries(content)) {
     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;
     result.encryptedPayload = encryptedPayload;
     return result;
     return result;
@@ -137,7 +170,7 @@ module.exports = (configPath) => {
         continue;
         continue;
       }
       }
     }
     }
-    return { ...content, encrypted: true };
+    return { ...content, _undecryptable: true };
   };
   };
 
 
   const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => {
   const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) => {
@@ -165,17 +198,85 @@ module.exports = (configPath) => {
     return sets;
     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();
   loadKeyring();
 
 
   return {
   return {
     SENSITIVE_FIELDS,
     SENSITIVE_FIELDS,
+    ENVELOPE_PRESERVE,
     loadKeyring, saveKeyring,
     loadKeyring, saveKeyring,
     generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
     generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
     encryptWithKey, decryptWithKey,
     encryptWithKey, decryptWithKey,
     encryptForInvite, decryptFromInvite,
     encryptForInvite, decryptFromInvite,
+    encryptChainForInvite, decryptChainFromInvite,
     encryptChain, decryptChain,
     encryptChain, decryptChain,
     encryptContent, decryptContent,
     encryptContent, decryptContent,
     boxKeyForMember, unboxKeyFromMember,
     boxKeyForMember, unboxKeyFromMember,
     buildKeyChainSets,
     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 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 publish = async (content) => {
     const ssbClient = await openSsb();
     const ssbClient = await openSsb();
@@ -52,18 +40,26 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     const tombstoned = new Set();
     const tombstoned = new Set();
     const replaced = new Map();
     const replaced = new Map();
     const items = new Map();
     const items = new Map();
+    const authorByKey = new Map();
+    const tombRequests = [];
 
 
     for (const m of msgs) {
     for (const m of msgs) {
       const c = m.value?.content;
       const c = m.value?.content;
       if (!c) continue;
       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;
       if (c.type !== TYPE) continue;
+      authorByKey.set(m.key, m.value?.author);
       if (tribeId && c.tribeId !== tribeId) continue;
       if (tribeId && c.tribeId !== tribeId) continue;
       if (contentType && c.contentType !== contentType) continue;
       if (contentType && c.contentType !== contentType) continue;
       if (c.replaces) replaced.set(c.replaces, m.key);
       if (c.replaces) replaced.set(c.replaces, m.key);
       items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
       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 id of tombstoned) items.delete(id);
     for (const oldId of replaced.keys()) items.delete(oldId);
     for (const oldId of replaced.keys()) items.delete(oldId);
 
 
@@ -74,9 +70,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         if (!result[i].encryptedPayload) continue;
         if (!result[i].encryptedPayload) continue;
         const tid = result[i].tribeId;
         const tid = result[i].tribeId;
         if (!keyChainCache.has(tid)) {
         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));
         result[i] = tribeCrypto.decryptContent(result[i], keyChainCache.get(tid));
       }
       }
@@ -145,6 +140,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
     async update(contentId, data, existing) {
     async update(contentId, data, existing) {
       if (!existing) existing = await this.getById(contentId);
       if (!existing) existing = await this.getById(contentId);
       if (!existing) throw new Error('Content not found');
       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)) {
       if (data.status && !VALID_STATUSES.includes(data.status)) {
         throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
         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 tombstoned = new Set();
       const replaced = new Map();
       const replaced = new Map();
       const items = new Map();
       const items = new Map();
+      const authorByKey = new Map();
+      const tombRequests = [];
 
 
       for (const m of msgs) {
       for (const m of msgs) {
         const c = m.value?.content;
         const c = m.value?.content;
         if (!c) continue;
         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;
         if (c.type !== TYPE) continue;
+        authorByKey.set(m.key, m.value?.author);
         if (c.replaces) replaced.set(c.replaces, m.key);
         if (c.replaces) replaced.set(c.replaces, m.key);
         items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
         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;
       let latestId = contentId;
       while (replaced.has(latestId)) latestId = replaced.get(latestId);
       while (replaced.has(latestId)) latestId = replaced.get(latestId);
       if (tombstoned.has(latestId)) return null;
       if (tombstoned.has(latestId)) return null;
       const item = items.get(latestId) || null;
       const item = items.get(latestId) || null;
       if (!item || !item.encryptedPayload || !tribeCrypto || !tribesModel) return item;
       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) {
     async listByTribe(tribeId, contentType, filter) {

+ 198 - 22
src/models/tribes_model.js

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

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

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

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@krakenslab/oasis",
   "name": "@krakenslab/oasis",
-  "version": "0.7.4",
+  "version": "0.7.5",
   "description": "Oasis - Social Networking Utopia",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
   "repository": {
     "type": "git",
     "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));
 let config = Config('ssb', minimist(conf));
 config = { ...config, ...configData };
 config = { ...config, ...configData };
 
 
-// Set blob size limit to 50MB
 const megabyte = Math.pow(2, 20);
 const megabyte = Math.pow(2, 20);
 config.blobs = config.blobs || {};
 config.blobs = config.blobs || {};
 config.blobs.max = 50 * megabyte;
 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) => {
 const getViewDetailsAction = (type, block) => {
+  if (block && block.content && typeof block.content.encryptedPayload === 'string') return null;
   switch (type) {
   switch (type) {
     case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
     case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
     case 'transfer': return `/transfers/${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 'chat': return `/chats/${encodeURIComponent(block.id)}`;
     case 'gameScore': return `/games?filter=scoring`;
     case 'gameScore': return `/games?filter=scoring`;
     case 'log': return `/logs/view/${encodeURIComponent(block.id)}`;
     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;
     default: return null;
   }
   }
 };
 };
@@ -173,9 +178,10 @@ const renderBlockDiagram = (blocks, qs) => {
       ].filter(Boolean).join(' | ') || '—';
       ].filter(Boolean).join(' | ') || '—';
 
 
       const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
       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' },
       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')
             span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
           ),
           ),
           div({ class: 'block-diagram-grid' },
           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 q = safeText(params.q || "")
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
   const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
   const isAuthor = String(chat.author) === String(userId)
   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 fullShareUrl = `/chats/${encodeURIComponent(chat.key)}`
   const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
   const isRestrictedInviteOnly = !isMember && !isAuthor && chat.status === "INVITE-ONLY"
 
 
@@ -250,7 +250,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
       ) : null
       ) : null
     ),
     ),
     isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
     isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
-      isAuthor
+      isAuthor && chat.status === "INVITE-ONLY"
         ? form({ method: "POST", action: `/chats/generate-invite` },
         ? form({ method: "POST", action: `/chats/generate-invite` },
             input({ type: "hidden", name: "chatId", value: chat.key }),
             input({ type: "hidden", name: "chatId", value: chat.key }),
             input({ type: "hidden", name: "returnTo", value: returnTo }),
             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) => {
 exports.singlePadView = async (pad, entries, params) => {
   const isAuthor = String(pad.author) === String(userId)
   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 padClosed = pad.isClosed
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
   const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
   const shareUrl = `/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 : 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" },
     isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
-      isAuthor
+      isAuthor && pad.status === "INVITE-ONLY"
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
         ? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
             button({ type: "submit", class: "tribe-action-btn" }, i18n.padGenerateCode || "Generate Code")
           )
           )
@@ -271,7 +271,7 @@ exports.singlePadView = async (pad, entries, params) => {
           )
           )
         )
         )
       : null,
       : null,
-    !isRestrictedInviteOnly && (!isAuthor && (pad.status === "OPEN" || isMember) && !padClosed)
+    !isRestrictedInviteOnly && !isAuthor && !isMember && pad.status === "OPEN" && !padClosed
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
       ? form({ method: "POST", action: `/pads/join/${encodeURIComponent(pad.rootId)}` },
           button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
           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),
         h2(title),
         p(description)
         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 =>
         modes.map(m =>
           form({ method: 'GET', action: '/stats' },
           form({ method: 'GET', action: '/stats' },
             input({ type: 'hidden', name: 'filter', value: m }),
             input({ type: 'hidden', name: 'filter', value: m }),
@@ -105,15 +105,15 @@ exports.statsView = (stats, filter) => {
       ),
       ),
       section(
       section(
         div({ style: headerStyle },
         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₂`)
                   span(`${networkCO2} g CO₂`)
                 ),
                 ),
                 div({ class: 'carbon-bar-track' },
                 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-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)'))
                 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₂`)
                   span(`${networkCO2} g CO₂`)
                 ),
                 ),
                 div({ class: 'carbon-bar-track' },
                 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-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)'))
                 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₂`)
                 span(`${maxAnnualCO2} g CO₂`)
               ),
               ),
               div({ class: 'carbon-bar-track' },
               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-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)'))
               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 },
         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 },
         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 },
         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)}`)),
         div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
@@ -233,7 +233,7 @@ exports.statsView = (stats, filter) => {
           ? div({ class: 'stats-container' }, [
           ? div({ class: 'stats-container' }, [
               div({ style: blockStyle },
               div({ style: blockStyle },
                 h2(i18n.statsActivity7d),
                 h2(i18n.statsActivity7d),
-                table({ style: 'width:100%; border-collapse: collapse;' },
+                table({ class: 'stats-table' },
                   tr(th(i18n.day), th(i18n.messages)),
                   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))))
                   ...(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 },
               div({ style: blockStyle },
                 h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
                 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))))
                   ...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({ class: 'stats-container' }, [
                 div({ style: blockStyle },
                 div({ style: blockStyle },
                   h2(i18n.statsActivity7d),
                   h2(i18n.statsActivity7d),
-                  table({ style: 'width:100%; border-collapse: collapse;' },
+                  table({ class: 'stats-table' },
                     tr(th(i18n.day), th(i18n.messages)),
                     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))))
                     ...(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 },
                 div({ style: blockStyle },
                   h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
                   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))))
                     ...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
                   )
                   )
                 ),
                 ),
                 Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
                 Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
                   ? div({ style: blockStyle },
                   ? div({ style: blockStyle },
                       h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
                       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))))
                         ...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 renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
   const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
   const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
+  const tribeId = safeText(params.tribeId || "");
   return div(
   return div(
     { class: "div-center audio-form" },
     { class: "div-center audio-form" },
     form(
     form(
@@ -214,6 +215,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
         enctype: "multipart/form-data"
         enctype: "multipart/form-data"
       },
       },
       input({ type: "hidden", name: "returnTo", value: returnTo }),
       input({ type: "hidden", name: "returnTo", value: returnTo }),
+      tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
       span(i18n.torrentFileLabel),
       span(i18n.torrentFileLabel),
       br(),
       br(),
       input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }),
       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 { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderUrl } = require('../backend/renderUrl');
@@ -197,7 +198,8 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
   );
   );
 
 
   const isEdit = filter === 'edit' && tribeId;
   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' },
   const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
     h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
     h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
     form({
     form({
@@ -229,13 +231,14 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
     br,
     br,
     input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
     input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
     br,
     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: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
       option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
       option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
     ),
     ),
-    br(), br(),
+    isSubEdit ? null : br(),
+    isSubEdit ? null : br(),
     label({ for: 'inviteMode' }, i18n.tribeModeLabel),
     label({ for: 'inviteMode' }, i18n.tribeModeLabel),
     br,
     br,
     select({ name: 'inviteMode', id: 'inviteMode' },
     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)
       option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
     ),
     ),
     br(), br(),
     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: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
       option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
       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)
     button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
     )
     )
   ) : null;
   ) : null;
@@ -258,27 +262,16 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
 
 
   const tribeCards = sorted.map(t => {
   const tribeCards = sorted.map(t => {
     const isMember = t.members.includes(userId);
     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;
     const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
 
 
     return div({ class: 'tribe-card' },
     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' },
       div({ class: 'tribe-card-image-wrapper' },
         a({ href: `/tribe/${encodeURIComponent(t.id)}` },
         a({ href: `/tribe/${encodeURIComponent(t.id)}` },
           renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' })
           renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' })
         ),
         ),
         isMember
         isMember
           ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-visit-btn-wrapper' },
           ? 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
           : 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,
         t.description ? p({ class: 'tribe-card-description' }, ...renderUrl(t.description)) : null,
         renderMapLocationVisitLabel(t.mapUrl),
         renderMapLocationVisitLabel(t.mapUrl),
         table({ class: 'tribe-info-table' },
         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(
           t.location ? tr(
             td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
             td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
             td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location))
             td({ class: 'tribe-info-value', colspan: '3' }, ...renderUrl(t.location))
           ) : null,
           ) : null,
-          tr(
+          !t.parentTribeId ? tr(
             td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
             td({ class: 'tribe-info-label' }, i18n.tribeIsAnonymousLabel || 'STATUS'),
             td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
             td({ class: 'tribe-info-value', colspan: '3' }, t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)
-          ),
+          ) : null,
           tr(
           tr(
             td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
             td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
             td({ class: 'tribe-info-value', colspan: '3' }, String(inviteModeI18n()[t.inviteMode] || t.inviteMode).toUpperCase())
             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-label' }, i18n.tribeLARPLabel || 'L.A.R.P.'),
             td({ class: 'tribe-info-value', colspan: '3' }, t.isLARP ? i18n.tribeYes : i18n.tribeNo)
             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' },
         div({ class: 'tribe-card-members' },
           span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.members.length}`)
           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 renderSectionNav = (tribe, section) => {
   const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
   const firstGroup = [{ key: 'activity', label: i18n.tribeSectionActivity }, { key: 'inhabitants', label: i18n.tribeSectionInhabitants }];
   if (!tribe.parentTribeId) firstGroup.push({ key: 'subtribes', label: i18n.tribeSubTribes });
   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 = [
   const sections = [
     { items: firstGroup },
     { items: firstGroup },
     { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
     { 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: '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: '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 =>
     sections.map(g =>
-      div({ class: 'tribe-section-group', style: 'border: none;' },
+      div({ class: 'tribe-section-group no-border' },
         g.items.map(s => s.href
         g.items.map(s => s.href
           ? form({ method: 'GET', action: s.href },
           ? form({ method: 'GET', action: s.href },
               button({ type: 'submit', class: 'filter-btn' }, s.label)
               button({ type: 'submit', class: 'filter-btn' }, s.label)
@@ -487,13 +474,14 @@ const contentTypeName = (ct) => {
 const activitySectionMap = {
 const activitySectionMap = {
   event: 'events', task: 'tasks', votation: 'votations',
   event: 'events', task: 'tasks', votation: 'votations',
   forum: 'forum', 'forum-reply': 'forum',
   forum: 'forum', 'forum-reply': 'forum',
-  feed: 'feed'
+  feed: 'feed',
+  pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents'
 };
 };
 
 
 const activitySectionForItem = (item) => {
 const activitySectionForItem = (item) => {
   if (item.contentType === 'media' && item.mediaType) {
   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';
   return activitySectionMap[item.contentType] || 'activity';
 };
 };
@@ -507,7 +495,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
   const { activities } = sectionData || { activities: [] };
   const { activities } = sectionData || { activities: [] };
   if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty));
   if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty));
   const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`;
   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 => {
     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')));
       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() : '';
       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)
         : 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)
           ? a({ href: item.url || item.description, target: '_blank', class: 'tribe-action-btn' }, item.url || item.description)
         : null;
         : null;
-      return div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      return div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, headerText),
           h2({ class: 'card-label' }, headerText),
           item.directUrl
           item.directUrl
@@ -543,7 +531,6 @@ const renderTribeActivitySection = (tribe, sectionData) => {
         ),
         ),
         div({ class: 'tribe-card-body' },
         div({ class: 'tribe-card-body' },
           item.title ? div({ class: 'card-field' },
           item.title ? div({ class: 'card-field' },
-            span({ class: 'card-label' }, (i18n.title || 'Title') + ':'),
             span({ class: 'card-value' }, item.title)
             span({ class: 'card-value' }, item.title)
           ) : null,
           ) : null,
           mediaContent,
           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 renderTribeSearchSection = (tribe, sectionData, query) => {
   const { results } = sectionData || { results: [] };
   const { results } = sectionData || { results: [] };
   const sq = sectionData?.query || '';
   const sq = sectionData?.query || '';
@@ -1235,10 +1267,6 @@ const renderSubTribesSection = (tribe, items, query) => {
     : tribe.author === userId;
     : tribe.author === userId;
 
 
   if (action === 'create' && canCreate) {
   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', [
     return renderCreateForm(tribe, 'subtribes', [
       { name: 'title', label: i18n.tribeTitleLabel, required: true, placeholder: 'Name of the sub-tribe' },
       { 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' },
       { 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: [
       { name: 'inviteMode', type: 'select', label: i18n.tribeModeLabel, options: [
         { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
         { value: 'open', label: i18n.tribeOpen }, { value: 'strict', label: i18n.tribeStrict }
       ], spaceBefore: true },
       ], 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' }),
       input({ type: 'hidden', name: 'action', value: 'create' }),
       button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
       button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeSubTribesCreate)
     ) : null,
     ) : null,
+    !canCreate && tribe.inviteMode === 'strict'
+      ? p({ class: 'tribe-strict-denied' }, i18n.tribeSubTribesStrictDenied)
+      : null,
     subTribes.length === 0
     subTribes.length === 0
       ? null
       ? null
       : div({ class: 'tribe-thumb-grid' },
       : div({ class: 'tribe-thumb-grid' },
@@ -1287,7 +1314,7 @@ const renderTribeMapsSection = (tribe, maps) => {
   return div({ class: 'tribe-content-list' },
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
     items.map(m =>
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`),
           h2({ class: 'card-label' }, `[${(i18n.typeMap || 'MAP').toUpperCase()}]`),
           form({ method: 'GET', action: `/maps/${encodeURIComponent(m.key)}` },
           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 renderTribePadsSection = (tribe, pads) => {
   const items = Array.isArray(pads) ? pads : [];
   const items = Array.isArray(pads) ? pads : [];
   const createBtn = form({ method: 'GET', action: '/pads' },
   const createBtn = form({ method: 'GET', action: '/pads' },
@@ -1320,7 +1386,7 @@ const renderTribePadsSection = (tribe, pads) => {
   return div({ class: 'tribe-content-list' },
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
     items.map(m =>
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
           h2({ class: 'card-label' }, `[${(i18n.typePad || 'PAD').toUpperCase()}]`),
           form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
           form({ method: 'GET', action: `/pads/${encodeURIComponent(m.rootId)}` },
@@ -1351,7 +1417,7 @@ const renderTribeChatsSection = (tribe, chats) => {
   return div({ class: 'tribe-content-list' },
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
     items.map(m =>
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
           h2({ class: 'card-label' }, `[${(i18n.typeChat || 'CHAT').toUpperCase()}]`),
           form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
           form({ method: 'GET', action: `/chats/${encodeURIComponent(m.key)}` },
@@ -1383,7 +1449,7 @@ const renderTribeCalendarsSection = (tribe, calendars) => {
   return div({ class: 'tribe-content-list' },
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
     items.map(m =>
     items.map(m =>
-      div({ class: 'card card-rpg', style: 'padding: 12px 16px;' },
+      div({ class: 'card card-rpg tribe-card-padded' },
         div({ class: 'card-header' },
         div({ class: 'card-header' },
           h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
           h2({ class: 'card-label' }, `[${(i18n.typeCalendar || 'CALENDAR').toUpperCase()}]`),
           form({ method: 'GET', action: `/calendars/${encodeURIComponent(m.rootId)}` },
           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 'inhabitants': sectionContent = renderInhabitantsSection(tribe, sectionData); break;
     case 'feed':
     case 'feed':
       sectionContent = div(
       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,
         ) : null,
         tribe.members.includes(config.keys.id)
         tribe.members.includes(config.keys.id)
           ? form({ class: 'tribe-feed-compose', method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/message` },
           ? 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 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break;
     case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
     case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
     case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); 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 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
     case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
     case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
     case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
     case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;
     case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
     case 'calendars': sectionContent = renderTribeCalendarsSection(tribe, sectionData); break;
     case 'governance': sectionContent = renderGovernance(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':
     case 'activity':
     default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
     default: sectionContent = renderTribeActivitySection(tribe, sectionData); break;
   }
   }
@@ -1543,8 +1611,8 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
       )) : null,
       )) : null,
     ),
     ),
     div({ class: 'tribe-main' },
     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,
       ) : null,
       renderSectionNav(tribe, section),
       renderSectionNav(tribe, section),
       sectionContent
       sectionContent
@@ -1561,16 +1629,16 @@ const GOVERNANCE_METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP',
 
 
 const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
 const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
   const row1 = [
   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 = [
   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' },
   const mkRow = (filters) => div({ class: 'mode-buttons-row' },
     filters.map(f =>
     filters.map(f =>
@@ -1587,13 +1655,26 @@ const governanceFilterBar = (tribeId, currentFilter, showPublish) => {
     showPublish
     showPublish
       ? div({ class: 'mode-buttons-row' },
       ? div({ class: 'mode-buttons-row' },
           form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribeId)}/governance/publish-candidature` },
           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
       : 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) => {
 const governmentCard = (tribe, term, leaders) => {
   if (!term) {
   if (!term) {
     return div({ class: 'card' },
     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.')
       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' },
   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
       : null
   );
   );
@@ -1623,22 +1745,25 @@ const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle)
       : null,
       : null,
     h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'),
     h3(i18n.tribeGovernanceProposeInternal || 'Propose internal candidature'),
     form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/candidature/propose` },
     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(),
       input({ type: 'text', name: 'candidateId', placeholder: '@...ed25519', required: true }), br(), br(),
-      label({}, i18n.parliamentCandidatureMethod || 'Method'), br(),
+      label({}, i18n.tribeGovCandidatureMethod || 'Method'), br(),
       select({ name: 'method' },
       select({ name: 'method' },
         GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
         GOVERNANCE_METHODS.map(m => option({ value: m }, i18n[`parliamentMethod${m}`] || m))
       ), br(), br(),
       ), 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'),
     h3(i18n.tribeGovernanceInternalCandidatures || 'Internal candidatures'),
     list.length === 0
     list.length === 0
       ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.')
       ? p(i18n.tribeGovernanceNoCandidatures || 'No open candidatures.')
       : ul({}, list.map(c =>
       : ul({}, list.map(c =>
           li({},
           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 }),
               input({ type: 'hidden', name: 'candidatureId', value: c.id }),
               button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote')
               button({ type: 'submit', class: 'filter-btn' }, i18n.vote || 'Vote')
             )
             )
@@ -1652,23 +1777,23 @@ const rulesBlock = (tribe, rules, isCreator) => div({},
     ? div({},
     ? div({},
         h3(i18n.tribeGovernanceAddRule || 'Add rule'),
         h3(i18n.tribeGovernanceAddRule || 'Add rule'),
         form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
         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(),
           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(),
           textarea({ name: 'body', rows: 4 }), br(), br(),
           button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
           button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
         )
         )
       )
       )
     : null,
     : null,
-  h3(i18n.parliamentFilterRules || 'Rules'),
+  h3(i18n.tribeGovRules || 'Rules'),
   (!Array.isArray(rules) || rules.length === 0)
   (!Array.isArray(rules) || rules.length === 0)
     ? p(i18n.tribeGovernanceNoRules || 'No rules yet.')
     ? p(i18n.tribeGovernanceNoRules || 'No rules yet.')
     : ul({}, rules.map(r =>
     : ul({}, rules.map(r =>
         li({},
         li({},
-          span({ style: 'font-weight:bold' }, r.title || '-'),
+          span({ class: 'bold' }, r.title || '-'),
           r.body ? p(r.body) : null,
           r.body ? p(r.body) : null,
           isCreator
           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 }),
                 input({ type: 'hidden', name: 'ruleId', value: r.id }),
                 button({ type: 'submit', class: 'filter-btn' }, i18n.delete || 'Delete')
                 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 === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle);
   else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator);
   else if (f === 'rules') body = rulesBlock(tribe, rules, !!isCreator);
   else if (f === 'leaders') body = div({ class: 'card' },
   else if (f === 'leaders') body = div({ class: 'card' },
-    h3(i18n.parliamentFilterLeaders || 'LEADERS'),
+    h3(i18n.tribeGovLeader || 'LEADERS'),
     (!Array.isArray(leaders) || leaders.length === 0)
     (!Array.isArray(leaders) || leaders.length === 0)
       ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.')
       ? 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' },
   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.')
     p(i18n.tribeGovernanceComingSoon || 'Coming soon in this tribe\'s governance module.')
   );
   );