Files
peardock/server/handlers/peers.js
T

270 lines
8.2 KiB
JavaScript

/**
* 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 } 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) live.role = role
return { success: true, data: entry }
})
}