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 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-04-29 00:58:05 +02:00
parent 5ee14d9e1d
commit 0fc10be24c
5 changed files with 94 additions and 7 deletions

View file

@ -4189,3 +4189,25 @@ form {
border-radius: 4px; border-radius: 4px;
margin: 8px 0; 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;
}

View file

@ -206,6 +206,9 @@ module.exports = ({ cooler }) => {
const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']); const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']);
filtered = filtered.filter(b => b && cset.has(b.type)); 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 s = search || {};
const authorQ = String(s.author || '').trim(); const authorQ = String(s.author || '').trim();

View file

@ -722,11 +722,21 @@ models.meta = {
acceptInvite: async (invite) => { acceptInvite: async (invite) => {
const ssb = await cooler.open(); const ssb = await cooler.open();
const code = toLegacyInvite(String(invite || '')); const code = toLegacyInvite(String(invite || ''));
return await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
ssb.invite.accept(code, (err, res) => { ssb.invite.accept(code, (err, res) => {
err ? reject(err) : resolve(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 () => { rebuild: async () => {
const ssb = await cooler.open(); const ssb = await cooler.open();

View file

@ -104,7 +104,9 @@ module.exports = ({ cooler }) => {
} }
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex'); const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, code] : [code]; 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; return code;
}, },
@ -138,7 +140,12 @@ module.exports = ({ cooler }) => {
} }
const members = [...tribe.members, userId]; const members = [...tribe.members, userId];
const invites = tribe.invites.filter(c => c !== code); 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; return tribe.id;
}, },
@ -202,6 +209,7 @@ module.exports = ({ cooler }) => {
createdAt: tribe.content.createdAt, createdAt: tribe.content.createdAt,
updatedAt: tribe.content.updatedAt, updatedAt: tribe.content.updatedAt,
author: tribe.content.author, author: tribe.content.author,
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
}; };
}, },
@ -230,6 +238,7 @@ module.exports = ({ cooler }) => {
createdAt: c.createdAt, createdAt: c.createdAt,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
author: c.author, author: c.author,
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
_ts: entry._ts _ts: entry._ts
}); });
} }

View file

@ -11,12 +11,13 @@ const FILTER_LABELS = {
forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub, forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe, transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim, 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 BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'parliament', 'courts']; 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_BLOCK3 = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
const CAT_BLOCK4 = ['forum', 'bookmark', 'image', 'video', 'audio', 'document']; 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 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
case 'contact': return `/inhabitants`; case 'contact': return `/inhabitants`;
case 'pub': return `/invites`; case 'pub': return `/invites`;
case 'pub-invite': return `/invites`;
case 'market': return `/market/${encodeURIComponent(block.id)}`; case 'market': return `/market/${encodeURIComponent(block.id)}`;
case 'job': return `/jobs/${encodeURIComponent(block.id)}`; case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
case 'project': return `/projects/${encodeURIComponent(block.id)}`; case 'project': return `/projects/${encodeURIComponent(block.id)}`;
@ -131,7 +133,47 @@ const TYPE_COLORS = {
parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad', parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad', parliamentCandidature:'#8e44ad', parliamentRevocation:'#8e44ad',
courtsCase:'#c0392b', courtsEvidence:'#c0392b', courtsAnswer:'#c0392b', 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) => { 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.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.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))) 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)
) )
) )
) )