Files
peardata/server/core/peer-policy.js
T
Raven Scott 015d92a257
Release rolling / release (push) Has been cancelled
CI / test (push) Has been cancelled
first commit
2026-07-18 16:17:38 -04:00

179 lines
4.8 KiB
JavaScript

/**
* Peer roles, revocations, capability spend tracking.
* File-backed under PEARDATA_DATA_DIR (default ./data).
*/
import fs from 'fs'
import path from 'path'
import { Roles } from '../../shared/protocol.js'
import { verifyCapability } from '../../shared/crypto-auth.js'
import { getMacKey } from './auth-keys.js'
import logger from '../utils/logger.js'
const log = logger.child('peer-policy')
function dataDir() {
return process.env.PEARDATA_DATA_DIR || path.resolve('data')
}
function policyPath() {
return path.join(dataDir(), 'peer-policy.json')
}
/** @type {{ peers: Record<string, { role: string, displayName?: string, firstSeen: string, lastSeen: string }>, revoked: string[], spentJti: string[] }} */
let state = { peers: {}, revoked: [], spentJti: [] }
export function loadPeerPolicy() {
try {
const p = policyPath()
if (fs.existsSync(p)) {
const raw = JSON.parse(fs.readFileSync(p, 'utf8'))
state = {
peers: raw.peers || {},
revoked: Array.isArray(raw.revoked) ? raw.revoked : [],
spentJti: Array.isArray(raw.spentJti) ? raw.spentJti.slice(-5000) : [],
}
log.info('Loaded peer policy', {
peers: Object.keys(state.peers).length,
revoked: state.revoked.length,
})
}
} catch (err) {
log.warn('Failed to load peer policy', { error: err.message })
}
}
function save() {
try {
const dir = dataDir()
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.writeFileSync(policyPath(), JSON.stringify(state, null, 2), { mode: 0o600 })
} catch (err) {
log.warn('Failed to save peer policy', { error: err.message })
}
}
/**
* @param {string} peerId
* @param {string} fallback
*/
export function resolvePeerRole(peerId, fallback) {
const id = String(peerId || '').toLowerCase()
const entry = state.peers[id]
if (entry?.role) return entry.role
return fallback
}
/**
* @param {string} peerId
*/
export function getPeerEntry(peerId) {
return state.peers[String(peerId || '').toLowerCase()] || null
}
/**
* @param {string} peerId
* @param {{ role: string, displayName?: string }} info
*/
export function registerPeer(peerId, info) {
const id = String(peerId || '').toLowerCase()
const now = new Date().toISOString()
const prev = state.peers[id]
state.peers[id] = {
role: info.role,
displayName: info.displayName || prev?.displayName,
firstSeen: prev?.firstSeen || now,
lastSeen: now,
}
save()
}
/**
* @param {string} peerId
* @param {string} [displayName]
*/
export function touchPeer(peerId, displayName) {
const id = String(peerId || '').toLowerCase()
const prev = state.peers[id]
if (!prev) return
prev.lastSeen = new Date().toISOString()
if (displayName) prev.displayName = displayName
save()
}
/**
* @param {string} peerId
*/
export function isPeerRevoked(peerId) {
return state.revoked.includes(String(peerId || '').toLowerCase())
}
/**
* @param {string} peerId
* @param {{ authMode?: string }} [opts]
*/
export function isPeerAllowed(peerId, opts = {}) {
const id = String(peerId || '').toLowerCase()
if (isPeerRevoked(id)) return false
const allow = (process.env.PEARDATA_ALLOWLIST || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
if (allow.length === 0) return true
// Seed/capability auth can onboard onto allowlist environments
if (opts.authMode === 'seed' || opts.authMode === 'capability') return true
return allow.includes(id) || Boolean(state.peers[id])
}
/**
* @param {string} peerId
*/
export function revokePeer(peerId) {
const id = String(peerId || '').toLowerCase()
if (!state.revoked.includes(id)) state.revoked.push(id)
delete state.peers[id]
save()
}
/**
* @param {string} token
* @param {string} peerId
* @returns {{ role: string, reconnected: boolean }}
*/
export function redeemCapability(token, peerId) {
const spent = new Set(state.spentJti)
const res = verifyCapability(getMacKey(), token, {
peerId,
allowSpentCheck: (jti) => {
// Already-registered peers may reconnect with same grant
if (spent.has(jti) && getPeerEntry(peerId)?.role) return true
return !spent.has(jti)
},
})
if (!res.ok) {
const err = new Error(res.error)
err.code = res.code
throw err
}
const jti = res.payload.jti
const existing = getPeerEntry(peerId)
const reconnected = Boolean(existing?.role) && spent.has(jti)
if (!spent.has(jti)) {
state.spentJti.push(jti)
if (state.spentJti.length > 5000) state.spentJti = state.spentJti.slice(-4000)
}
registerPeer(peerId, { role: res.payload.role })
save()
return { role: res.payload.role, reconnected }
}
export function listPolicyPeers() {
return Object.entries(state.peers).map(([id, p]) => ({
peerId: id,
...p,
revoked: isPeerRevoked(id),
}))
}