From 0fc10be24c070ff14b18c62e2068c278c812d697 Mon Sep 17 00:00:00 2001 From: SITO Date: Wed, 29 Apr 2026 00:58:05 +0200 Subject: [PATCH] feat: invite trazability via inviteLog and pub-invite SSB events tribes_model: generateInvite() stores inviteLog[] entry {code, generatedBy, generatedAt, status} joinByInvite() updates inviteLog entry with {usedBy, usedAt, status:'used'} main_models: acceptInvite() publishes type:'pub-invite' SSB message after handshake records pubHost, pubKey, acceptedAt - first audit trail for pub invites blockchain_model: adds 'invites' filter showing pub-invite blocks and tribes with inviteLog blockchain_view: renders inviteLog table on tribe blocks, renders pub-invite block details adds 'invites' filter button to blockexplorer UI Co-Authored-By: Claude Sonnet 4.6 --- .../src/client/assets/styles/style.css | 22 ++++++++ .../src/models/blockchain_model.js | 3 ++ .../nodejs-project/src/models/main_models.js | 12 ++++- .../nodejs-project/src/models/tribes_model.js | 13 ++++- .../src/views/blockchain_view.js | 51 +++++++++++++++++-- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/nodejs-project/nodejs-project/src/client/assets/styles/style.css b/nodejs-project/nodejs-project/src/client/assets/styles/style.css index 73d9f227..eca8703e 100644 --- a/nodejs-project/nodejs-project/src/client/assets/styles/style.css +++ b/nodejs-project/nodejs-project/src/client/assets/styles/style.css @@ -4189,3 +4189,25 @@ form { border-radius: 4px; margin: 8px 0; } + +/* Invite log blockchain styles */ +.invite-log-block { + margin-top: 8px; + padding: 6px 8px; + border-left: 3px solid #27ae60; + background: rgba(39,174,96,0.06); + border-radius: 0 4px 4px 0; +} + +.invite-status--pending { color: #e67e22; font-weight: bold; } +.invite-status--used { color: #27ae60; font-weight: bold; } + +.invite-code-cell { + font-family: monospace; + font-size: 11px; +} + +.invite-key { + font-size: 11px; + word-break: break-all; +} diff --git a/nodejs-project/nodejs-project/src/models/blockchain_model.js b/nodejs-project/nodejs-project/src/models/blockchain_model.js index 4992d09a..dc39aac1 100644 --- a/nodejs-project/nodejs-project/src/models/blockchain_model.js +++ b/nodejs-project/nodejs-project/src/models/blockchain_model.js @@ -206,6 +206,9 @@ module.exports = ({ cooler }) => { const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']); filtered = filtered.filter(b => b && cset.has(b.type)); } + if (filter === 'INVITES' || filter === 'invites') { + filtered = filtered.filter(b => b && (b.type === 'pub-invite' || (b.type === 'tribe' && Array.isArray(b.content?.inviteLog) && b.content.inviteLog.length > 0))); + } const s = search || {}; const authorQ = String(s.author || '').trim(); diff --git a/nodejs-project/nodejs-project/src/models/main_models.js b/nodejs-project/nodejs-project/src/models/main_models.js index 306a08b7..06718f23 100644 --- a/nodejs-project/nodejs-project/src/models/main_models.js +++ b/nodejs-project/nodejs-project/src/models/main_models.js @@ -722,11 +722,21 @@ models.meta = { acceptInvite: async (invite) => { const ssb = await cooler.open(); const code = toLegacyInvite(String(invite || '')); - return await new Promise((resolve, reject) => { + const result = await new Promise((resolve, reject) => { ssb.invite.accept(code, (err, res) => { err ? reject(err) : resolve(res); }); }); + const { host, pubId } = parseRemote(code); + await new Promise((resolve) => { + ssb.publish({ + type: 'pub-invite', + pubHost: host || null, + pubKey: pubId || null, + acceptedAt: new Date().toISOString(), + }, (err) => resolve()); + }); + return result; }, rebuild: async () => { const ssb = await cooler.open(); diff --git a/nodejs-project/nodejs-project/src/models/tribes_model.js b/nodejs-project/nodejs-project/src/models/tribes_model.js index c3797a63..c656d6a3 100644 --- a/nodejs-project/nodejs-project/src/models/tribes_model.js +++ b/nodejs-project/nodejs-project/src/models/tribes_model.js @@ -104,7 +104,9 @@ module.exports = ({ cooler }) => { } const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex'); const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code]; - await this.updateTribeInvites(tribeId, invites); + 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 }); return code; }, @@ -138,7 +140,12 @@ module.exports = ({ cooler }) => { } const members = [...tribe.members, userId]; const invites = tribe.invites.filter(c => c !== code); - await this.updateTribeById(tribe.id, { members, invites }); + const inviteLog = Array.isArray(tribe.inviteLog) ? tribe.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; }, @@ -202,6 +209,7 @@ module.exports = ({ cooler }) => { createdAt: tribe.content.createdAt, updatedAt: tribe.content.updatedAt, author: tribe.content.author, + inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [], }; }, @@ -230,6 +238,7 @@ module.exports = ({ cooler }) => { createdAt: c.createdAt, updatedAt: c.updatedAt, author: c.author, + inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [], _ts: entry._ts }); } diff --git a/nodejs-project/nodejs-project/src/views/blockchain_view.js b/nodejs-project/nodejs-project/src/views/blockchain_view.js index b5d73238..07f52451 100644 --- a/nodejs-project/nodejs-project/src/views/blockchain_view.js +++ b/nodejs-project/nodejs-project/src/views/blockchain_view.js @@ -11,12 +11,13 @@ const FILTER_LABELS = { forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub, transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe, project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim, - aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts + aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts, + 'pub-invite': 'PUB INVITE', invites: 'INVITES' }; const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone']; const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'parliament', 'courts']; -const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange']; +const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange', 'invites']; const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia']; const CAT_BLOCK4 = ['forum', 'bookmark', 'image', 'video', 'audio', 'document']; @@ -97,6 +98,7 @@ const getViewDetailsAction = (type, block) => { case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`; case 'contact': return `/inhabitants`; case 'pub': return `/invites`; + case 'pub-invite': return `/invites`; case 'market': return `/market/${encodeURIComponent(block.id)}`; case 'job': return `/jobs/${encodeURIComponent(block.id)}`; case 'project': return `/projects/${encodeURIComponent(block.id)}`; @@ -131,7 +133,47 @@ const TYPE_COLORS = { parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad', parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad', courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b', - courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b' + courtsVerdict:'#c0392b', courtsSettlement:'#c0392b', courtsNomination:'#c0392b', + 'pub-invite': '#27ae60', invites: '#27ae60' +}; + +const renderInviteExtra = (block) => { + if (block.type === 'pub-invite') { + const c = block.content; + return div({ class: 'invite-log-block' }, + table({ class: 'block-info-table' }, + tr(td({ class: 'card-label' }, 'PUB HOST'), td({ class: 'card-value' }, c.pubHost || '—')), + tr(td({ class: 'card-label' }, 'PUB KEY'), td({ class: 'card-value invite-key' }, c.pubKey ? a({ href: `/author/${encodeURIComponent(c.pubKey)}`, class: 'user-link' }, c.pubKey) : '—')), + tr(td({ class: 'card-label' }, 'ACCEPTED'), td({ class: 'card-value' }, c.acceptedAt ? moment(c.acceptedAt).format('YYYY-MM-DD HH:mm:ss') : '—')) + ) + ); + } + if (block.type === 'tribe' && Array.isArray(block.content?.inviteLog) && block.content.inviteLog.length > 0) { + return div({ class: 'invite-log-block' }, + strong('INVITE LOG:'), + table({ class: 'block-info-table' }, + tr( + td({ class: 'card-label' }, 'CODE'), + td({ class: 'card-label' }, 'GENERATED BY'), + td({ class: 'card-label' }, 'GENERATED AT'), + td({ class: 'card-label' }, 'STATUS'), + td({ class: 'card-label' }, 'USED BY'), + td({ class: 'card-label' }, 'USED AT') + ), + ...block.content.inviteLog.map(entry => + tr( + td({ class: 'card-value invite-code-cell' }, String(entry.code || '').slice(0, 12) + '…'), + td({ class: 'card-value' }, entry.generatedBy ? a({ href: `/author/${encodeURIComponent(entry.generatedBy)}`, class: 'user-link' }, String(entry.generatedBy).slice(0, 16) + '…') : '—'), + td({ class: 'card-value' }, entry.generatedAt ? moment(entry.generatedAt).format('YYYY-MM-DD HH:mm') : '—'), + td({ class: `card-value invite-status invite-status--${entry.status || 'pending'}` }, (entry.status || 'pending').toUpperCase()), + td({ class: 'card-value' }, entry.usedBy ? a({ href: `/author/${encodeURIComponent(entry.usedBy)}`, class: 'user-link' }, String(entry.usedBy).slice(0, 16) + '…') : '—'), + td({ class: 'card-value' }, entry.usedAt ? moment(entry.usedAt).format('YYYY-MM-DD HH:mm') : '—') + ) + ) + ) + ); + } + return null; }; const renderBlockDiagram = (blocks, qs) => { @@ -365,7 +407,8 @@ const renderBlockchainView = (blocks, filter, userId, search = {}) => { tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)), tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())), tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author))) - ) + ), + renderInviteExtra(block) ) ) )