feat: merge oasis 0.7.5 upstream — tribes ACL, 4 new languages, model/view updates

Manual merge of epsylon/oasis 0.7.5 preserving our mobile-specific work:
- tribes_model: upstream ACL security (validMembershipDelta, structuralFieldsEqual)
  + our inviteLog feature re-applied in generateInvite/joinByInvite
- tribes_view: isSubEdit hides isAnonymous/isLARP when editing subtribes
- blockchain_view: new block types (calendarDate, calendarNote, padEntry, chatMessage),
  bd-type-* CSS classes replacing inline styles, encrypted payload filter
- style.css: removed old tribe-parent classes, added bd-type-* and stats utility classes
  (mobile.css/OasisMobile.css untouched — our mobile work)
- backend.js: calendarsModel/torrentsModel/mapsModel now receive tribeCrypto+tribesModel,
  moved after tribesModel init; ensureFollowTribeMembers call in /tribe/:tribeId route
- translations: updated de/en/es/eu/fr/it/pt + 4 new languages ar/hi/ru/zh
- models (safe copy): calendars, chats, maps, pads, parliament, torrents,
  tribe_crypto, tribes_content
- views (safe copy): chats, pads, torrents, stats
- package.json: bumped to 0.7.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-05-04 15:59:46 +02:00
parent bb2d7acfd9
commit 9764a0c162
34 changed files with 14235 additions and 373 deletions

View file

@ -345,13 +345,11 @@ const tasksModel = require('../models/tasks_model')({ cooler, isPublic: config.p
const votesModel = require('../models/votes_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 calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
const feedModel = require('../models/feed_model')({ cooler, isPublic: config.public });
const imagesModel = require("../models/images_model")({ cooler, isPublic: config.public });
const audiosModel = require("../models/audios_model")({ cooler, isPublic: config.public });
const torrentsModel = require("../models/torrents_model")({ cooler, isPublic: config.public });
const videosModel = require("../models/videos_model")({ cooler, isPublic: config.public });
const documentsModel = require("../models/documents_model")({ cooler, isPublic: config.public });
const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public });
@ -370,8 +368,10 @@ const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic
const jobsModel = require('../models/jobs_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 calendarsModel = require('../models/calendars_model')({ cooler, pmModel, tribeCrypto, tribesModel });
const torrentsModel = require("../models/torrents_model")({ cooler, tribeCrypto, tribesModel });
const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
const mapsModel = require("../models/maps_model")({ cooler, tribeCrypto, tribesModel });
const gamesModel = require('../models/games_model')({ cooler });
const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
const logsModel = require("../models/logs_model")({ cooler });
@ -1379,6 +1379,7 @@ router
return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; });
};
const tribe = await tribesModel.getTribeById(ctx.params.tribeId);
tribesModel.ensureFollowTribeMembers(ctx.params.tribeId).catch(() => {});
const uid = getViewerId();
const query = { feedFilter: 'TOP', ...ctx.query };
if (!tribe.members.includes(uid)) {

View file

@ -3642,17 +3642,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
font-style: italic;
}
.tribe-card-subtribes {
border: 1px solid #555;
border-radius: 4px;
padding: 8px;
margin: 6px 0;
background: #1e1f23;
display: flex;
flex-direction: column;
gap: 4px;
}
.tribe-card-members {
border: none;
border-radius: 4px;
@ -3737,26 +3726,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end;
}
.tribe-parent-box {
margin-bottom: 12px;
text-align: center;
}
.tribe-parent-box h2 {
margin: 0 0 8px 0;
}
.tribe-parent-link {
display: block;
}
.tribe-parent-image {
width: 100%;
max-width: 200px;
border-radius: 8px;
border: 2px solid #444;
}
.tribe-card-meta-row {
display: flex;
gap: .5em;
@ -4154,42 +4123,49 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end;
}
.tribe-parent-box {
margin-bottom: 12px;
text-align: center;
}
.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}
.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;
}
.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}
.comment-submit-btn {
width: auto;

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,12 +7,18 @@ const categories = require('../backend/opinion_categories');
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
module.exports = ({ cooler }) => {
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const TYPE = 'tribe-content';
const resolveKeyChain = async (tribeId) =>
(tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChain(tribeId, tribesModel) : null;
const resolveKeyChainSets = async (tribeId) =>
(tribeCrypto && tribesModel) ? tribeCrypto.resolveKeyChainSets(tribeId, tribesModel) : null;
const publish = async (content) => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
@ -30,26 +36,48 @@ module.exports = ({ cooler }) => {
);
};
const buildIndex = (msgs, tribeId, contentType) => {
const buildIndex = async (msgs, tribeId, contentType) => {
const tombstoned = new Set();
const replaced = new Map();
const items = new Map();
const authorByKey = new Map();
const tombRequests = [];
for (const m of msgs) {
const c = m.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
if (c.type !== TYPE) continue;
authorByKey.set(m.key, m.value?.author);
if (tribeId && c.tribeId !== tribeId) continue;
if (contentType && c.contentType !== contentType) continue;
if (c.replaces) replaced.set(c.replaces, m.key);
items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
}
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target);
if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
}
for (const id of tombstoned) items.delete(id);
for (const oldId of replaced.keys()) items.delete(oldId);
return [...items.values()].sort((a, b) => {
const result = [...items.values()];
if (tribeCrypto && tribesModel) {
const keyChainCache = new Map();
for (let i = 0; i < result.length; i++) {
if (!result[i].encryptedPayload) continue;
const tid = result[i].tribeId;
if (!keyChainCache.has(tid)) {
const sets = await tribeCrypto.resolveKeyChainSets(tid, tribesModel);
keyChainCache.set(tid, sets || []);
}
result[i] = tribeCrypto.decryptContent(result[i], keyChainCache.get(tid));
}
}
return result.sort((a, b) => {
const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
return tb - ta;
@ -102,12 +130,17 @@ module.exports = ({ cooler }) => {
createdAt: now,
updatedAt: now,
};
const keyChain = await resolveKeyChain(tribeId);
if (keyChain && keyChain.length > 0) {
return publish(tribeCrypto.encryptContent(content, keyChain));
}
return publish(content);
},
async update(contentId, data, existing) {
if (!existing) existing = await this.getById(contentId);
if (!existing) throw new Error('Content not found');
if (existing._undecryptable) throw new Error('Content is tribe-encrypted and cannot be decrypted with available keys');
if (data.status && !VALID_STATUSES.includes(data.status)) {
throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
}
@ -149,6 +182,10 @@ module.exports = ({ cooler }) => {
createdAt: existing.createdAt,
updatedAt: now,
};
const keyChain = await resolveKeyChain(existing.tribeId);
if (keyChain && keyChain.length > 0) {
return publish(tribeCrypto.encryptContent(updated, keyChain));
}
return publish(updated);
},
@ -167,25 +204,35 @@ module.exports = ({ cooler }) => {
const tombstoned = new Set();
const replaced = new Map();
const items = new Map();
const authorByKey = new Map();
const tombRequests = [];
for (const m of msgs) {
const c = m.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
if (c.type === 'tombstone' && c.target) { tombRequests.push({ target: c.target, author: m.value?.author }); continue; }
if (c.type !== TYPE) continue;
authorByKey.set(m.key, m.value?.author);
if (c.replaces) replaced.set(c.replaces, m.key);
items.set(m.key, { id: m.key, ...c, _ts: m.value?.timestamp });
}
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target);
if (targetAuthor && t.author === targetAuthor) tombstoned.add(t.target);
}
let latestId = contentId;
while (replaced.has(latestId)) latestId = replaced.get(latestId);
if (tombstoned.has(latestId)) return null;
return items.get(latestId) || null;
const item = items.get(latestId) || null;
if (!item || !item.encryptedPayload || !tribeCrypto || !tribesModel) return item;
const keyChainSets = await tribeCrypto.resolveKeyChainSets(item.tribeId, tribesModel);
return tribeCrypto.decryptContent(item, keyChainSets || []);
},
async listByTribe(tribeId, contentType, filter) {
const msgs = await readLog();
let items = buildIndex(msgs, tribeId, contentType);
let items = await buildIndex(msgs, tribeId, contentType);
if (filter === 'open') items = items.filter(i => i.status === 'OPEN');
if (filter === 'closed') items = items.filter(i => i.status === 'CLOSED');

View file

@ -6,13 +6,52 @@ const logLimit = getConfig().ssbLogStream?.limit || 1000;
const INVITE_CODE_BYTES = 16;
const VALID_INVITE_MODES = ['strict', 'open'];
module.exports = ({ cooler }) => {
module.exports = ({ cooler, tribeCrypto }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
let tribeIndex = null;
let tribeIndexTs = 0;
const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isLARP', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
const arraysEqual = (a, b) => {
const aa = Array.isArray(a) ? a : [];
const bb = Array.isArray(b) ? b : [];
if (aa.length !== bb.length) return false;
for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false;
return true;
};
const validMembershipDelta = (prevMembers, nextMembers, author) => {
const prev = Array.isArray(prevMembers) ? prevMembers : [];
const next = Array.isArray(nextMembers) ? nextMembers : [];
const added = next.filter(m => !prev.includes(m));
const removed = prev.filter(m => !next.includes(m));
if (added.length === 0 && removed.length === 0) return true;
if (added.length === 1 && removed.length === 0 && added[0] === author) return true;
if (removed.length === 1 && added.length === 0 && removed[0] === author) return true;
return false;
};
const validInvitesDelta = (prevInvites, nextInvites, author, rootAuthor) => {
if (author === rootAuthor) return true;
const prevCodes = new Set((prevInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
const nextCodes = new Set((nextInvites || []).map(i => typeof i === 'string' ? i : i?.code).filter(Boolean));
for (const c of nextCodes) if (!prevCodes.has(c)) return false;
return true;
};
const structuralFieldsEqual = (prev, next) => {
for (const f of STRUCTURAL_FIELDS) {
const a = prev[f];
const b = next[f];
if (Array.isArray(a) || Array.isArray(b)) { if (!arraysEqual(a, b)) return false; continue; }
if (a !== b && !(a == null && b == null)) return false;
}
return true;
};
const buildTribeIndex = async () => {
if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
const client = await openSsb();
@ -21,23 +60,68 @@ module.exports = ({ cooler }) => {
client.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tombstoned = new Set();
const parent = new Map();
const child = new Map();
const tribes = new Map();
const tombstones = new Map();
const tribeMsgs = new Map();
for (const msg of msgs) {
const k = msg.key;
const c = msg.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
if (c.type !== 'tribe') continue;
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
const author = msg.value?.author;
if (c.type === 'tombstone' && c.target) {
tombstones.set(c.target, { author, ts: msg.value?.timestamp });
continue;
}
tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
if (c.type !== 'tribe') continue;
tribeMsgs.set(k, { id: k, content: c, author, _ts: msg.value?.timestamp });
}
const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
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;
}
}
const tombstoned = new Set();
for (const [target, t] of tombstones.entries()) {
const tribeEntry = tribes.get(target);
if (!tribeEntry) continue;
const root = rootByTip.get(target);
const rootAuthor = tribes.get(root)?.author;
if (t.author === rootAuthor) tombstoned.add(target);
}
const rootOf = (id) => rootByTip.get(id) || id;
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
const tipByRoot = new Map();
for (const k of tribes.keys()) {
@ -45,7 +129,7 @@ module.exports = ({ cooler }) => {
const tip = tipOf(root);
tipByRoot.set(root, tip);
}
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip };
tribeIndexTs = Date.now();
resolve(tribeIndex);
})
@ -56,7 +140,7 @@ module.exports = ({ cooler }) => {
return {
type: 'tribe',
async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN') {
async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
if (!VALID_INVITE_MODES.includes(inviteMode)) {
throw new Error('Invalid invite mode. Must be "strict" or "open"');
}
@ -83,11 +167,16 @@ module.exports = ({ cooler }) => {
inviteMode,
status: status || 'OPEN',
parentTribeId: parentTribeId || null,
mapUrl: String(mapUrl || '').trim(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: userId,
};
const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
if (tribeCrypto) {
const tribeKey = tribeCrypto.generateTribeKey();
tribeCrypto.setKey(result.key, tribeKey, 1);
}
tribeIndex = null;
return result;
},
@ -103,7 +192,15 @@ module.exports = ({ cooler }) => {
throw new Error('Only tribe members can generate invites in open mode');
}
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code];
let invite = code;
if (tribeCrypto) {
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 inviteLog = Array.isArray(tribe.inviteLog) ? [...tribe.inviteLog] : [];
inviteLog.push({ code, generatedBy: userId, generatedAt: new Date().toISOString(), status: 'pending', usedBy: null, usedAt: null });
await this.updateTribeById(tribeId, { invites, inviteLog });
@ -126,27 +223,75 @@ module.exports = ({ cooler }) => {
const idx = members.indexOf(userId);
if (idx === -1) throw new Error('User is not a member of this tribe');
members.splice(idx, 1);
return this.updateTribeById(tribeId, { members });
await this.updateTribeById(tribeId, { members });
await this.rotateTribeKey(tribeId, members);
},
async joinByInvite(code) {
const ssb = await openSsb();
const userId = ssb.id;
const tribes = await this.listAll();
const tribe = tribes.find(t => t.invites && t.invites.includes(code));
if (!tribe) throw new Error('Invalid or expired invite code');
if (tribe.members.includes(userId)) {
let matchedTribe = null;
let matchedInvite = null;
for (const t of tribes) {
if (!t.invites) continue;
for (const inv of t.invites) {
if (typeof inv === 'string' && inv === code) {
matchedTribe = t; matchedInvite = inv; break;
}
if (typeof inv === 'object' && inv.code === code) {
matchedTribe = t; matchedInvite = inv; break;
}
}
if (matchedTribe) break;
}
if (!matchedTribe) throw new Error('Invalid or expired invite code');
if (matchedTribe.members.includes(userId)) {
throw new Error('Already a member of this tribe');
}
const members = [...tribe.members, userId];
const invites = tribe.invites.filter(c => c !== code);
const inviteLog = Array.isArray(tribe.inviteLog) ? tribe.inviteLog.map(entry =>
entry.code === code
? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() }
: entry
let storedTribeKey = null;
let storedGen = 1;
let storedRootId = null;
if (tribeCrypto && typeof matchedInvite === 'object') {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
storedRootId = chain[0].rootId;
storedTribeKey = chain[0].key;
storedGen = chain[0].gen || 1;
}
} else if (matchedInvite.ek) {
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
storedRootId = await this.getRootId(matchedTribe.id);
storedGen = matchedInvite.gen || 1;
tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
}
}
const members = [...matchedTribe.members, userId];
const invites = matchedTribe.invites.filter(inv => {
if (typeof inv === 'string') return inv !== code;
return inv.code !== code;
});
const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry =>
entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry
) : [];
await this.updateTribeById(tribe.id, { members, invites, inviteLog });
return tribe.id;
await this.updateTribeById(matchedTribe.id, { members, invites, inviteLog });
if (tribeCrypto && storedTribeKey && storedRootId) {
const ssbKeys = require('../server/node_modules/ssb-keys');
const memberKeys = {};
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(storedTribeKey, userId, ssbKeys); } catch (_) {}
if (matchedTribe.author && matchedTribe.author !== userId) {
try { memberKeys[matchedTribe.author] = tribeCrypto.boxKeyForMember(storedTribeKey, matchedTribe.author, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssb.publish({ type: 'tribe-keys', tribeId: storedRootId, generation: storedGen, memberKeys }, () => resolve());
});
}
}
await this.ensureFollowTribeMembers(matchedTribe.id).catch(() => {});
return matchedTribe.id;
},
async deleteTribeById(tribeId) {
@ -154,7 +299,61 @@ module.exports = ({ cooler }) => {
},
async updateTribeMembers(tribeId, members) {
return this.updateTribeById(tribeId, { members });
const tribe = await this.getTribeById(tribeId);
const oldMembers = tribe.members || [];
await this.updateTribeById(tribeId, { members });
const removed = oldMembers.filter(m => !members.includes(m));
const added = members.filter(m => !oldMembers.includes(m));
if (removed.length > 0) {
await this.rotateTribeKey(tribeId, members);
} else if (added.length > 0) {
await this.distributeTribeKey(tribeId, added);
}
},
async distributeTribeKey(tribeId, toMembers) {
if (!tribeCrypto) return;
const ssb = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const rootId = await this.getRootId(tribeId);
const currentKey = tribeCrypto.getKey(rootId);
if (!currentKey) return;
const gen = tribeCrypto.getGen(rootId);
const memberKeys = {};
for (const memberId of toMembers) {
try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
}
if (!Object.keys(memberKeys).length) return;
await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res));
});
await this.ensureFollowTribeMembers(tribeId).catch(() => {});
},
async ensureTribeKeyDistribution(tribeId) {
if (!tribeCrypto) return;
const ssb = await openSsb();
const userId = ssb.id;
const tribe = await this.getTribeById(tribeId).catch(() => null);
if (!tribe || tribe.author !== userId) return;
const rootId = await this.getRootId(tribeId);
const currentKey = tribeCrypto.getKey(rootId);
if (!currentKey) return;
const gen = tribeCrypto.getGen(rootId);
const msgs = await new Promise((resolve, reject) => {
pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
});
const distributed = new Set();
for (const m of msgs) {
const c = m.value?.content;
if (!c || c.type !== 'tribe-keys') continue;
if (c.tribeId !== rootId) continue;
if ((c.generation || 0) < gen) continue;
for (const mid of Object.keys(c.memberKeys || {})) distributed.add(mid);
}
const members = Array.isArray(tribe.members) ? tribe.members : [];
const missing = members.filter(m => m !== userId && !distributed.has(m));
if (missing.length > 0) await this.distributeTribeKey(tribeId, missing);
},
async publishUpdatedTribe(tribeId, updatedTribe) {
@ -174,6 +373,7 @@ module.exports = ({ cooler }) => {
inviteMode: updatedTribe.inviteMode,
status: updatedTribe.status || 'OPEN',
parentTribeId: updatedTribe.parentTribeId || null,
mapUrl: updatedTribe.mapUrl || "",
createdAt: updatedTribe.createdAt,
updatedAt: new Date().toISOString(),
author: updatedTribe.author,
@ -206,6 +406,7 @@ module.exports = ({ cooler }) => {
inviteMode: tribe.content.inviteMode || 'strict',
status: tribe.content.status || 'OPEN',
parentTribeId: tribe.content.parentTribeId || null,
mapUrl: tribe.content.mapUrl || "",
createdAt: tribe.content.createdAt,
updatedAt: tribe.content.updatedAt,
author: tribe.content.author,
@ -214,7 +415,12 @@ module.exports = ({ cooler }) => {
},
async listAll() {
const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
const resolveParent = (pid) => {
if (!pid) return null;
const root = rootByTip.get(pid) || pid;
return tipByRoot.get(root) || pid;
};
const items = [];
for (const [root, tip] of tipByRoot) {
if (tombstoned.has(root) || tombstoned.has(tip)) continue;
@ -234,7 +440,8 @@ module.exports = ({ cooler }) => {
invites: Array.isArray(c.invites) ? c.invites : [],
inviteMode: c.inviteMode || 'strict',
status: c.status || 'OPEN',
parentTribeId: c.parentTribeId || null,
parentTribeId: resolveParent(c.parentTribeId),
mapUrl: c.mapUrl || "",
createdAt: c.createdAt,
updatedAt: c.updatedAt,
author: c.author,
@ -254,6 +461,148 @@ module.exports = ({ cooler }) => {
while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
return ids;
},
async getRootId(tribeId) {
const { parent } = await buildTribeIndex();
let root = tribeId;
while (parent.has(root)) root = parent.get(root);
return root;
},
async getAncestryChain(tribeId) {
const rootId = await this.getRootId(tribeId);
const tribe = await this.getTribeById(tribeId);
const chain = [rootId];
let currentTribe = tribe;
while (currentTribe.parentTribeId) {
const parentRootId = await this.getRootId(currentTribe.parentTribeId);
chain.push(parentRootId);
try {
currentTribe = await this.getTribeById(currentTribe.parentTribeId);
} catch (e) {
break;
}
}
return chain;
},
async rotateTribeKey(tribeId, remainingMembers) {
if (!tribeCrypto) return;
const ssb = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const rootId = await this.getRootId(tribeId);
const oldKey = tribeCrypto.getKey(rootId);
if (!oldKey) return;
const newKey = tribeCrypto.generateTribeKey();
const newGen = tribeCrypto.addNewKey(rootId, newKey);
const memberKeys = {};
for (const memberId of remainingMembers) {
memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys);
}
const entries = Object.entries(memberKeys);
const BATCH_SIZE = 20;
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch },
(err, res) => err ? reject(err) : resolve(res));
});
}
const tribe = await this.getTribeById(tribeId);
if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]);
const updatedInvites = tribe.invites.map(inv => {
if (typeof inv === 'object' && inv.code) {
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code);
if (ekChain) return { code: inv.code, ekChain, gen: newGen };
return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
}
return inv;
});
await this.updateTribeInvites(tribeId, updatedInvites);
}
},
async processIncomingKeys() {
if (!tribeCrypto) return;
const ssb = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const msgs = await new Promise((resolve, reject) => {
pull(
ssb.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
for (const m of msgs) {
const c = m.value?.content;
if (!c || c.type !== 'tribe-keys') continue;
const myEntry = c.memberKeys && c.memberKeys[ssb.id];
if (!myEntry) continue;
const currentGen = tribeCrypto.getGen(c.tribeId);
if (c.generation <= currentGen) continue;
const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
if (newKey) {
tribeCrypto.addNewKey(c.tribeId, newKey);
}
}
},
async ensureFollowTribeMembers(tribeId) {
const ssb = await openSsb();
const me = ssb.id;
let tribe;
try { tribe = await this.getTribeById(tribeId); } catch { return; }
const rootId = await this.getRootId(tribeId).catch(() => tribeId);
const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
const tribeRootSet = new Set([rootId]);
const tribeChainSet = new Set(tribeChainIds);
tribeChainSet.add(tribeId);
const discovered = new Set();
const myFollows = new Map();
await new Promise((resolve, reject) => {
pull(
ssb.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
for (const m of msgs) {
const v = m.value;
if (!v) continue;
const c = v.content;
if (!c) continue;
if (v.author === me && c.type === 'contact' && c.contact && typeof c.following === 'boolean') {
myFollows.set(c.contact, c.following);
continue;
}
if (c.type === 'tribe-keys' && c.tribeId && tribeRootSet.has(c.tribeId) && c.memberKeys && typeof c.memberKeys === 'object') {
for (const fid of Object.keys(c.memberKeys)) discovered.add(fid);
if (v.author) discovered.add(v.author);
continue;
}
if (c.type === 'tribe' && Array.isArray(c.members)) {
if (tribeChainSet.has(m.key) || tribeChainSet.has(c.replaces || '')) {
for (const fid of c.members) if (fid) discovered.add(fid);
if (c.author) discovered.add(c.author);
}
}
}
resolve();
})
);
});
const baseMembers = Array.isArray(tribe.members) ? tribe.members : [];
for (const fid of baseMembers) discovered.add(fid);
if (tribe.author) discovered.add(tribe.author);
discovered.delete(me);
const members = [...discovered].filter(Boolean);
if (!members.length) return;
for (const memberId of members) {
if (myFollows.get(memberId) === true) continue;
await new Promise((resolve) => {
ssb.publish({ type: 'contact', contact: memberId, following: true }, () => resolve());
});
}
},
async updateTribeById(tribeId, updatedContent) {
const ssb = await openSsb();
@ -293,6 +642,70 @@ module.exports = ({ cooler }) => {
const parentRoot = rootOf(parentId);
const all = await this.listAll();
return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
},
async isTribeMember(userId, tribeId) {
if (!userId || !tribeId) return false;
try {
const tribe = await this.getTribeById(tribeId);
if (!tribe) return false;
if (tribe.author === userId) return true;
return Array.isArray(tribe.members) && tribe.members.includes(userId);
} catch (e) {
return false;
}
},
async canAccessTribe(userId, tribeId) {
if (!userId || !tribeId) return false;
try {
const tribe = await this.getTribeById(tribeId);
if (!tribe) return false;
if (tribe.author === userId) return true;
if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
const effective = await this.getEffectiveStatus(tribeId);
return !effective.isPrivate;
} catch (e) {
return false;
}
},
async getEffectiveStatus(tribeId) {
let current;
try { current = await this.getTribeById(tribeId); } catch (e) { return { isPrivate: true, chain: [] }; }
const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
let cursor = current;
const seen = new Set([current.id]);
while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
seen.add(cursor.parentTribeId);
try {
cursor = await this.getTribeById(cursor.parentTribeId);
chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
} catch (e) { break; }
}
const isPrivate = chain.some(c => c.isAnonymous);
return { isPrivate, chain };
},
async listTribesForViewer(userId) {
const all = await this.listAll();
const out = [];
for (const t of all) {
if (!t.isAnonymous) { out.push(t); continue; }
if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
}
return out;
},
async getViewerTribeScope(userId) {
const all = await this.listAll();
const memberOf = new Set();
const createdBy = new Set();
for (const t of all) {
if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
}
return { memberOf, createdBy, allTribes: all };
}
};
};

View file

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

View file

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

View file

@ -79,6 +79,7 @@ const generateFilterButtons = (filters, currentFilter, action, search = {}) =>
);
const getViewDetailsAction = (type, block) => {
if (block && block.content && typeof block.content.encryptedPayload === 'string') return null;
switch (type) {
case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
@ -119,6 +120,10 @@ const getViewDetailsAction = (type, block) => {
case 'courtsSettlementAccepted': return `/courts`;
case 'courtsNomination': return `/courts`;
case 'courtsNominationVote': return `/courts`;
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;
}
};
@ -196,9 +201,10 @@ const renderBlockDiagram = (blocks, qs) => {
].filter(Boolean).join(' | ') || '—';
const datagramQs = qs ? `${qs}&view=datagram` : '?view=datagram';
const typeClass = `bd-type-${String(block.type || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '-')}`;
return a({ href: `/blockexplorer/block/${encodeURIComponent(block.id)}${datagramQs}`, class: 'block-diagram-link' },
div({ class: 'block-diagram', style: `border-color:${color};` },
div({ class: 'block-diagram-ruler', style: `border-bottom-color:${color};` },
div({ class: `block-diagram ${typeClass}` },
div({ class: 'block-diagram-ruler' },
span('0'), span('4'), span('8'), span('16'), span('24'), span('31')
),
div({ class: 'block-diagram-grid' },

View file

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

View file

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

View file

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

View file

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

View file

@ -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 QRCode = require('../server/node_modules/qrcode');
const { template, i18n } = require('./main_views');
const { config } = require('../server/SSB_server.js');
@ -201,7 +202,8 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
);
const isEdit = filter === 'edit' && tribeId;
const tribeToEdit = isEdit ? tribes.find(t => t.id === tribeId) : {};
const tribeToEdit = (isEdit ? tribes.find(t => t.id === tribeId) : null) || {};
const isSubEdit = isEdit && !!tribeToEdit.parentTribeId;
const createForm = (filter === 'create' || isEdit) ? div({ class: 'create-tribe-form' },
h2(isEdit ? i18n.updateTribeTitle : i18n.createTribeTitle),
form({
@ -233,23 +235,25 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
br,
input({ type: 'text', name: 'tags', id: 'tags', placeholder: i18n.tribeTagsPlaceholder, value: (tribeToEdit.tags || []).join(', ') }),
br,
label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
br,
select({ name: 'isAnonymous', id: 'isAnonymous' },
isSubEdit ? null : label({ for: 'isAnonymous' }, i18n.tribeIsAnonymousLabel),
isSubEdit ? null : br,
isSubEdit ? null : select({ name: 'isAnonymous', id: 'isAnonymous' },
option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
),
br(), br(),
isSubEdit ? null : br(),
isSubEdit ? null : br(),
label({ for: 'inviteMode' }, i18n.tribeModeLabel),
br,
select({ name: 'inviteMode', id: 'inviteMode' },
option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
),
br(), br(),
label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
br,
select({ name: 'isLARP', id: 'isLARP' },
isSubEdit ? null : br(),
isSubEdit ? null : br(),
isSubEdit ? null : label({ for: 'isLARP' }, i18n.tribeIsLARPLabel),
isSubEdit ? null : br,
isSubEdit ? null : select({ name: 'isLARP', id: 'isLARP' },
option({ value: 'false', selected: tribeToEdit.isLARP !== true ? 'selected' : undefined }, i18n.tribeNo),
option({ value: 'true', selected: tribeToEdit.isLARP === true ? 'selected' : undefined }, i18n.tribeYes)
),