Release rolling / release (push) Successful in 7m37s
Flush alert config immediately, re-arm runtime without restart, and push role updates to connected clients so ACL changes apply live.
283 lines
8.5 KiB
JavaScript
283 lines
8.5 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, 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) }
|
|
})
|
|
}
|