/** * Peer invite / revoke / list ACL management (admin). * Invites are pd1. share strings embedding public key + HMAC capability. */ import * as peerPolicy from '../core/peer-policy.js' import * as connectionInvites from '../core/connection-invites.js' import { peers } from '../core/peer-registry.js' import { Roles, Pushes } from '../../shared/protocol.js' import * as validation from '../utils/validation.js' import { decodePeardockInvite } from '../../shared/crypto-auth.js' export function registerPeerHandlers(session) { session.respond('listPeers', async () => { const policy = peerPolicy.listPeers() const live = [] for (const s of peers) { live.push({ peerId: s.id, role: s.role, authMode: s.authMode || null, connected: !s.closed, clientInfo: s.clientInfo || null, }) } return { success: true, type: 'peers', ...policy, live, } }) session.respond('listInvites', async () => { const active = connectionInvites.listConnectionInvites() const caps = peerPolicy.listInvites() const usedJtis = new Set(active.map((a) => a.jti).filter(Boolean)) // Active pd1 invites (one card each, grant embedded) const paired = active.map((a) => ({ kind: 'pd1', invite: a.invite || a.share || a.token, token: a.invite || a.share || a.token, share: a.share || a.invite || a.token, jti: a.jti || null, role: a.role || null, expiresAt: a.expiresAt ?? null, maxUses: a.maxUses ?? 0, uses: a.uses ?? 0, persistent: a.persistent ?? false, note: a.note || null, createdAt: a.createdAt || null, publicKeyHex: a.publicKeyHex || null, grant: a.grant || { kind: 'capability', jti: a.jti, role: a.role, expiresAt: a.expiresAt, maxUses: a.maxUses, uses: a.uses, persistent: a.persistent, embeddedInInvite: true, }, })) // Other capability grants still in policy (not the active pd1 slot) const standaloneCaps = caps .filter((c) => c.kind === 'capability' && c.jti && !usedJtis.has(c.jti)) .map((c) => ({ kind: 'capability', invite: null, token: c.token || null, share: null, jti: c.jti, role: c.role, expiresAt: c.expiresAt, maxUses: c.maxUses, uses: c.uses, persistent: c.persistent, note: c.note, createdAt: c.createdAt, grant: { kind: 'capability', jti: c.jti, role: c.role, expiresAt: c.expiresAt, maxUses: c.maxUses, uses: c.uses, persistent: c.persistent, embeddedInInvite: false, token: c.token || null, }, })) const legacy = caps .filter((c) => c.kind === 'legacy') .map((c) => ({ kind: 'legacy', invite: null, token: c.token, share: c.token, jti: null, role: c.role, expiresAt: c.expiresAt, maxUses: c.maxUses, uses: c.uses, note: c.note, createdAt: c.createdAt, grant: { kind: 'legacy', token: c.token, role: c.role }, })) return { success: true, type: 'invites', data: [...paired, ...standaloneCaps, ...legacy], } }) session.respond('invitePeer', async (args) => { // Register a known peer public key (no invite string) if (args.peerId) { const peerId = validation.sanitizeString(args.peerId, 64).toLowerCase() if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId must be 64 hex characters') const role = args.role || Roles.operator const entry = peerPolicy.registerPeer(peerId, { role, alias: args.alias || null, note: args.note || null, }) return { success: true, type: 'peerRegistered', data: entry } } const invite = connectionInvites.createConnectionInvite({ role: args.role || Roles.operator, ttlHours: args.ttlHours, maxUses: args.maxUses, note: args.note, alias: args.alias, }) return { success: true, type: 'invite', data: { token: invite.invite, invite: invite.invite, share: invite.share || invite.invite, kind: 'pd1', role: invite.role, expiresAt: invite.expiresAt, maxUses: invite.maxUses, jti: invite.jti, publicKeyHex: invite.publicKeyHex, redeemHint: invite.redeemHint, note: invite.note, persistent: invite.persistent, capability: invite.capability, }, } }) session.respond('deleteInvite', async (args) => { const kind = args.kind ? String(args.kind) : null let jti = args.jti ? validation.sanitizeString(String(args.jti), 64).toLowerCase() : null const tokenStr = args.token != null ? String(args.token) : args.invite != null ? String(args.invite) : null if (!kind && !jti && !tokenStr) { const err = new Error('Provide kind, jti, or token to delete an invite') err.code = 'INVALID_ARGS' throw err } const deleted = [] const looksLikePd1 = Boolean(tokenStr) && tokenStr.toLowerCase().startsWith('pd1.') const active = connectionInvites.listConnectionInvites() const matchesActive = kind === 'pd1' || looksLikePd1 || (jti && active.some((i) => i.jti === jti)) || (tokenStr && active.some((i) => i.invite === tokenStr || i.token === tokenStr || i.share === tokenStr)) if (matchesActive || looksLikePd1) { if (!jti && tokenStr) { const pkg = decodePeardockInvite(tokenStr) if (pkg?.jti) jti = pkg.jti } const wiped = connectionInvites.deleteConnectionInvite({ jti, token: tokenStr }) for (const d of wiped.deleted || []) { if (!deleted.includes(d)) deleted.push(d) } } // Standalone capability / legacy policy entries const isCapabilityToken = Boolean(tokenStr && tokenStr.includes('.') && !looksLikePd1) const isLegacyToken = Boolean(tokenStr && /^[0-9a-f]{48}$/i.test(tokenStr)) if (jti || isCapabilityToken || isLegacyToken) { const r = peerPolicy.deleteInvite({ jti: jti || undefined, token: isCapabilityToken || isLegacyToken ? tokenStr : undefined, }) for (const d of r.deleted || []) { if (!deleted.includes(d)) deleted.push(d) } } if (!deleted.length) { const err = new Error('Invite not found') err.code = 'INVITE_INVALID' throw err } return { success: true, type: 'inviteDeleted', deleted, } }) session.respond('revokePeer', async (args) => { const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase() if (!peerId) throw new Error('peerId required') const result = peerPolicy.revokePeer(peerId) const live = peers.get(peerId) if (live) { try { live.destroy() } catch { // ignore } } return { success: true, ...result } }) session.respond('unrevokePeer', async (args) => { const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase() if (!peerId) throw new Error('peerId required') if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId must be 64 hex characters') return { success: true, type: 'peerUnrevoked', ...peerPolicy.unrevokePeer(peerId) } }) session.respond('clearRevokedPeers', async () => { const result = peerPolicy.clearRevokedPeers() return { success: true, type: 'revokedCleared', ...result } }) session.respond('listRevokedPeers', async () => { const revoked = peerPolicy.listRevokedPeers() return { success: true, type: 'revokedPeers', data: revoked.map((peerId) => ({ peerId, revoked: true })), revoked, } }) session.respond('setPeerRole', async (args) => { const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase() const role = args.role if (!peerId || !role) throw new Error('peerId and role required') const entry = peerPolicy.setPeerRole(peerId, role) const live = peers.get(peerId) if (live) { // Apply ACL live — no reconnect / server restart live.role = role try { live.push(Pushes.session, { type: 'roleUpdate', role, peerId, updatedAt: new Date().toISOString(), }) } catch { // ignore push failures } } return { success: true, data: entry, appliedLive: Boolean(live) } }) }