676 lines
20 KiB
JavaScript
676 lines
20 KiB
JavaScript
/**
|
|
* Peer allowlist / revoke / capability grant policy.
|
|
*
|
|
* File: PEARDOCK_PEER_POLICY (default ./peardock-peers.json)
|
|
*
|
|
* Modes:
|
|
* - Open (default): any peer may connect as viewer (or elevated via capability/seed)
|
|
* - Allowlist (PEARDOCK_PEER_ALLOWLIST=1): only listed peers, seed-admin, or
|
|
* holders of a valid capability may connect
|
|
*
|
|
* Invites: HMAC capability grants (minted via signCapability) tracked by jti.
|
|
* pd1. invites embed the package; this file tracks spent jtis + peer roles.
|
|
*
|
|
* Legacy random invite tokens: only when PEARDOCK_LEGACY_INVITES=1.
|
|
*/
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import crypto from 'crypto'
|
|
import { Roles } from '../../shared/protocol.js'
|
|
import { signCapability, verifyCapability } from '../../shared/crypto-auth.js'
|
|
import { getMacKey } from './auth-keys.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
function policyFilePath() {
|
|
return process.env.PEARDOCK_PEER_POLICY || path.join(process.cwd(), 'peardock-peers.json')
|
|
}
|
|
|
|
function enforceAllowlist() {
|
|
return (
|
|
process.env.PEARDOCK_PEER_ALLOWLIST === '1' || process.env.PEARDOCK_PEER_ALLOWLIST === 'true'
|
|
)
|
|
}
|
|
|
|
function legacyInvitesEnabled() {
|
|
return (
|
|
process.env.PEARDOCK_LEGACY_INVITES === '1' || process.env.PEARDOCK_LEGACY_INVITES === 'true'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ peerId: string, role: string, alias?: string|null, invitedAt?: string, note?: string }} PeerEntry
|
|
* @typedef {{ token: string, role: string, expiresAt: string, maxUses: number, uses: number, note?: string, createdAt: string }} LegacyInvite
|
|
* @typedef {{ jti: string, role: string, exp: number, maxUses: number, uses: number, note?: string|null, createdAt: string, peerId?: string|null }} CapabilityMeta
|
|
*/
|
|
|
|
/**
|
|
* @returns {{
|
|
* version: number,
|
|
* revoked: string[],
|
|
* peers: Record<string, PeerEntry>,
|
|
* invites: Record<string, LegacyInvite>,
|
|
* capabilities: Record<string, CapabilityMeta>,
|
|
* spentJtis: string[],
|
|
* }}
|
|
*/
|
|
function loadPolicy() {
|
|
const POLICY_PATH = policyFilePath()
|
|
if (!fs.existsSync(POLICY_PATH)) {
|
|
return {
|
|
version: 2,
|
|
revoked: [],
|
|
peers: {},
|
|
invites: {},
|
|
capabilities: {},
|
|
spentJtis: [],
|
|
}
|
|
}
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8'))
|
|
return {
|
|
version: raw.version || 2,
|
|
revoked: Array.isArray(raw.revoked) ? raw.revoked.map((s) => String(s).toLowerCase()) : [],
|
|
peers: raw.peers && typeof raw.peers === 'object' ? raw.peers : {},
|
|
invites: raw.invites && typeof raw.invites === 'object' ? raw.invites : {},
|
|
capabilities: raw.capabilities && typeof raw.capabilities === 'object' ? raw.capabilities : {},
|
|
spentJtis: Array.isArray(raw.spentJtis) ? raw.spentJtis.map(String) : [],
|
|
}
|
|
} catch (err) {
|
|
logger.warn('peer policy load failed', { error: err.message })
|
|
return {
|
|
version: 2,
|
|
revoked: [],
|
|
peers: {},
|
|
invites: {},
|
|
capabilities: {},
|
|
spentJtis: [],
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof loadPolicy>} policy
|
|
*/
|
|
function savePolicy(policy) {
|
|
const POLICY_PATH = policyFilePath()
|
|
const dir = path.dirname(POLICY_PATH)
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
// Cap spent jti list growth
|
|
if (policy.spentJtis.length > 5000) {
|
|
policy.spentJtis = policy.spentJtis.slice(-2500)
|
|
}
|
|
fs.writeFileSync(POLICY_PATH, JSON.stringify(policy, null, 2), { mode: 0o600 })
|
|
try {
|
|
fs.chmodSync(POLICY_PATH, 0o600)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a peer is revoked (hard deny at TCP/DHT accept).
|
|
* @param {string} peerIdHex
|
|
*/
|
|
export function isPeerRevoked(peerIdHex) {
|
|
const id = (peerIdHex || '').toLowerCase()
|
|
const policy = loadPolicy()
|
|
return policy.revoked.includes(id)
|
|
}
|
|
|
|
/**
|
|
* Whether a peer may establish an RPC session (after handshake auth).
|
|
* Seed-admin and capability holders are allowed even under allowlist.
|
|
* @param {string} peerIdHex
|
|
* @param {{ authMode?: string }} [opts]
|
|
*/
|
|
export function isPeerAllowed(peerIdHex, opts = {}) {
|
|
const id = (peerIdHex || '').toLowerCase()
|
|
const policy = loadPolicy()
|
|
if (policy.revoked.includes(id)) return false
|
|
|
|
// Seed / capability / prior registration always allowed (unless revoked)
|
|
if (
|
|
opts.authMode === 'seed' ||
|
|
opts.authMode === 'capability' ||
|
|
opts.authMode === 'registered'
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (!enforceAllowlist()) return true
|
|
|
|
if (policy.peers[id]) return true
|
|
const adminKeys = (process.env.PEARDOCK_ADMIN_KEYS || '')
|
|
.split(',')
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
if (adminKeys.includes(id)) return true
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Resolve effective role: policy peer entry overrides env defaults.
|
|
* @param {string} peerIdHex
|
|
* @param {string} envRole
|
|
*/
|
|
export function resolvePeerRole(peerIdHex, envRole) {
|
|
const id = (peerIdHex || '').toLowerCase()
|
|
const policy = loadPolicy()
|
|
if (policy.revoked.includes(id)) return Roles.viewer
|
|
const entry = policy.peers[id]
|
|
if (entry?.role && [Roles.viewer, Roles.operator, Roles.admin].includes(entry.role)) {
|
|
return entry.role
|
|
}
|
|
return envRole
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerIdHex
|
|
* @param {{ role?: string, alias?: string, note?: string }} meta
|
|
*/
|
|
export function registerPeer(peerIdHex, meta = {}) {
|
|
const id = peerIdHex.toLowerCase()
|
|
if (!/^[0-9a-f]{64}$/.test(id)) throw new Error('peerId must be 64 hex characters')
|
|
const policy = loadPolicy()
|
|
policy.revoked = policy.revoked.filter((r) => r !== id)
|
|
policy.peers[id] = {
|
|
peerId: id,
|
|
role: meta.role || Roles.operator,
|
|
alias: meta.alias || null,
|
|
note: meta.note || null,
|
|
invitedAt: policy.peers[id]?.invitedAt || new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
savePolicy(policy)
|
|
return policy.peers[id]
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerIdHex
|
|
*/
|
|
export function revokePeer(peerIdHex) {
|
|
const id = peerIdHex.toLowerCase()
|
|
const policy = loadPolicy()
|
|
if (!policy.revoked.includes(id)) policy.revoked.push(id)
|
|
delete policy.peers[id]
|
|
savePolicy(policy)
|
|
return { success: true, peerId: id, revoked: true }
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerIdHex
|
|
*/
|
|
export function unrevokePeer(peerIdHex) {
|
|
const id = peerIdHex.toLowerCase()
|
|
if (!/^[0-9a-f]{64}$/.test(id)) throw new Error('peerId must be 64 hex characters')
|
|
const policy = loadPolicy()
|
|
const before = policy.revoked.length
|
|
policy.revoked = policy.revoked.filter((r) => r !== id)
|
|
savePolicy(policy)
|
|
return {
|
|
success: true,
|
|
peerId: id,
|
|
revoked: false,
|
|
removed: before !== policy.revoked.length,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove all entries from the revoke list.
|
|
* @returns {{ success: boolean, cleared: number }}
|
|
*/
|
|
export function clearRevokedPeers() {
|
|
const policy = loadPolicy()
|
|
const cleared = policy.revoked.length
|
|
policy.revoked = []
|
|
savePolicy(policy)
|
|
return { success: true, cleared }
|
|
}
|
|
|
|
/**
|
|
* @returns {string[]}
|
|
*/
|
|
export function listRevokedPeers() {
|
|
return loadPolicy().revoked.slice()
|
|
}
|
|
|
|
/**
|
|
* @param {string} peerIdHex
|
|
* @param {string} role
|
|
*/
|
|
export function setPeerRole(peerIdHex, role) {
|
|
if (![Roles.viewer, Roles.operator, Roles.admin].includes(role)) {
|
|
throw new Error('Invalid role')
|
|
}
|
|
const id = peerIdHex.toLowerCase()
|
|
const policy = loadPolicy()
|
|
if (!policy.peers[id]) {
|
|
policy.peers[id] = {
|
|
peerId: id,
|
|
role,
|
|
alias: null,
|
|
invitedAt: new Date().toISOString(),
|
|
}
|
|
} else {
|
|
policy.peers[id].role = role
|
|
policy.peers[id].updatedAt = new Date().toISOString()
|
|
}
|
|
savePolicy(policy)
|
|
return policy.peers[id]
|
|
}
|
|
|
|
/**
|
|
* Parse invite TTL hours. 0 / omitted = forever (default).
|
|
* Env PEARDOCK_INVITE_TTL_HOURS overrides default when opts unset.
|
|
* @param {number|undefined|null} ttlHours
|
|
* @returns {{ forever: boolean, ttlHours: number }}
|
|
*/
|
|
function resolveInviteTtl(ttlHours) {
|
|
const raw =
|
|
ttlHours !== undefined && ttlHours !== null && ttlHours !== ''
|
|
? Number(ttlHours)
|
|
: process.env.PEARDOCK_INVITE_TTL_HOURS !== undefined
|
|
? Number(process.env.PEARDOCK_INVITE_TTL_HOURS)
|
|
: 0
|
|
if (!Number.isFinite(raw) || raw <= 0) return { forever: true, ttlHours: 0 }
|
|
return { forever: false, ttlHours: Math.min(raw, 24 * 365 * 100) }
|
|
}
|
|
|
|
/**
|
|
* Parse max uses. 0 / omitted = unlimited (default).
|
|
* Env PEARDOCK_INVITE_MAX_USES overrides default when opts unset.
|
|
* @param {number|undefined|null} maxUses
|
|
* @returns {number} 0 = unlimited
|
|
*/
|
|
function resolveInviteMaxUses(maxUses) {
|
|
const raw =
|
|
maxUses !== undefined && maxUses !== null && maxUses !== ''
|
|
? Number(maxUses)
|
|
: process.env.PEARDOCK_INVITE_MAX_USES !== undefined
|
|
? Number(process.env.PEARDOCK_INVITE_MAX_USES)
|
|
: 0
|
|
if (!Number.isFinite(raw) || raw <= 0) return 0
|
|
return Math.min(Math.floor(raw), 1_000_000)
|
|
}
|
|
|
|
/**
|
|
* Mint an HMAC capability grant and track it for spend accounting.
|
|
* Defaults: never expires, unlimited uses (persist forever unless configured).
|
|
* @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, peerId?: string|null }} opts
|
|
*/
|
|
export function mintCapability(opts = {}) {
|
|
const role = opts.role || Roles.operator
|
|
const { forever, ttlHours } = resolveInviteTtl(opts.ttlHours)
|
|
const maxUses = resolveInviteMaxUses(opts.maxUses)
|
|
const macKey = getMacKey()
|
|
const { token, payload } = signCapability(macKey, {
|
|
role,
|
|
forever,
|
|
ttlMs: forever ? 0 : ttlHours * 3600 * 1000,
|
|
peerId: opts.peerId || null,
|
|
})
|
|
|
|
const policy = loadPolicy()
|
|
// Never leave a freshly minted jti on the spent list (corrupt state after bad deletes)
|
|
policy.spentJtis = (policy.spentJtis || []).filter((j) => j !== payload.jti)
|
|
policy.capabilities[payload.jti] = {
|
|
jti: payload.jti,
|
|
role: payload.role,
|
|
exp: payload.exp, // null = never
|
|
maxUses, // 0 = unlimited
|
|
uses: 0,
|
|
note: opts.note || null,
|
|
peerId: opts.peerId || null,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
savePolicy(policy)
|
|
|
|
return {
|
|
capability: token,
|
|
role: payload.role,
|
|
expiresAt: payload.exp == null ? null : new Date(payload.exp).toISOString(),
|
|
maxUses,
|
|
jti: payload.jti,
|
|
note: opts.note || null,
|
|
persistent: forever && maxUses === 0,
|
|
redeemHint:
|
|
forever && maxUses === 0
|
|
? 'Persistent grant — reconnect anytime with this capability (or after first redeem, as the registered peer).'
|
|
: 'Pass capability in handshake (or paste a full pd1. invite in Add peer)',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Look up a registered peer entry (or null).
|
|
* @param {string} peerIdHex
|
|
*/
|
|
export function getPeerEntry(peerIdHex) {
|
|
const id = (peerIdHex || '').toLowerCase()
|
|
if (!id) return null
|
|
const policy = loadPolicy()
|
|
return policy.peers[id] || null
|
|
}
|
|
|
|
/**
|
|
* Verify capability token and register peer with grant role.
|
|
* - Unlimited invites (maxUses=0): never spent; safe for reconnect.
|
|
* - Limited invites: count a use only when a *new* peer is registered (not reconnect).
|
|
* - If grant is spent/expired but this peer is already registered → reconnect with stored role.
|
|
* @param {string} token
|
|
* @param {string} peerIdHex
|
|
* @returns {{ role: string, jti: string, entry: PeerEntry, reconnected?: boolean }}
|
|
*/
|
|
export function redeemCapability(token, peerIdHex) {
|
|
const id = (peerIdHex || '').toLowerCase()
|
|
const policy = loadPolicy()
|
|
let spent = new Set(policy.spentJtis)
|
|
const existing = policy.peers[id] || null
|
|
|
|
const res = verifyCapability(getMacKey(), token, {
|
|
peerId: id,
|
|
allowSpentCheck: (jti) => {
|
|
// Reconnect of an already-registered elevated peer always allowed
|
|
if (existing?.role && existing.role !== 'viewer') return true
|
|
const meta = policy.capabilities[jti]
|
|
// Active unlimited (or not-yet-exhausted) grant must not be blocked by a stale spent list
|
|
if (meta) {
|
|
if (meta.exp != null && meta.exp < Date.now()) return false
|
|
if (meta.maxUses > 0 && meta.uses >= meta.maxUses) return false
|
|
// Heal: grant still active in policy but jti was marked spent (e.g. bad delete)
|
|
if (spent.has(jti)) {
|
|
spent.delete(jti)
|
|
policy.spentJtis = Array.from(spent)
|
|
savePolicy(policy)
|
|
logger.info('Healed spent jti still present as active capability', {
|
|
jti: String(jti).slice(0, 8),
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
if (spent.has(jti)) return false
|
|
// Untracked but valid HMAC — allow (persistent default); not auto-spent
|
|
return true
|
|
},
|
|
})
|
|
|
|
if (!res.ok) {
|
|
// Soft reconnect: registered elevated peer keeps role even if grant later revoked
|
|
if (
|
|
existing?.role &&
|
|
existing.role !== 'viewer' &&
|
|
(res.code === 'CAPABILITY_SPENT' || res.code === 'CAPABILITY_EXPIRED')
|
|
) {
|
|
return {
|
|
role: existing.role,
|
|
jti: existing.note?.startsWith('capability:')
|
|
? existing.note.slice('capability:'.length)
|
|
: null,
|
|
entry: existing,
|
|
reconnected: true,
|
|
}
|
|
}
|
|
const err = new Error(
|
|
res.error ||
|
|
'Invalid capability' +
|
|
(res.code === 'CAPABILITY_SPENT'
|
|
? ' (grant was deleted or replaced — request a new peardock invite)'
|
|
: '')
|
|
)
|
|
err.code = res.code || 'CAPABILITY_INVALID'
|
|
// Best-effort jti for logs (payload may be unreadable if MAC failed)
|
|
try {
|
|
const body = token?.split?.('.')?.[0]
|
|
if (body) {
|
|
const json = JSON.parse(
|
|
Buffer.from(body.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
)
|
|
err.jti = json?.jti || null
|
|
err.grantRole = json?.role || null
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
throw err
|
|
}
|
|
|
|
const jti = res.payload.jti
|
|
const meta = policy.capabilities[jti]
|
|
const alreadyRegistered = Boolean(existing?.role)
|
|
|
|
// Count use only for first-time peer registration on limited grants
|
|
if (meta && !alreadyRegistered && meta.maxUses > 0) {
|
|
meta.uses += 1
|
|
if (meta.uses >= meta.maxUses) {
|
|
spent.add(jti)
|
|
delete policy.capabilities[jti]
|
|
} else {
|
|
policy.capabilities[jti] = meta
|
|
}
|
|
policy.spentJtis = Array.from(spent)
|
|
savePolicy(policy)
|
|
} else if (meta && meta.exp != null && meta.exp < Date.now() && !alreadyRegistered) {
|
|
const err = new Error('Capability expired')
|
|
err.code = 'CAPABILITY_EXPIRED'
|
|
throw err
|
|
}
|
|
|
|
// Prefer grant role; never demote an existing higher registration
|
|
const grantRole = res.payload.role
|
|
const entry = registerPeer(id, {
|
|
role: existing?.role
|
|
? maxRoleLocal(existing.role, grantRole)
|
|
: grantRole,
|
|
note: existing?.note || `capability:${String(jti).slice(0, 8)}`,
|
|
})
|
|
return {
|
|
role: entry.role,
|
|
jti,
|
|
entry,
|
|
reconnected: alreadyRegistered,
|
|
}
|
|
}
|
|
|
|
function maxRoleLocal(a, b) {
|
|
const rank = { viewer: 1, operator: 2, admin: 3 }
|
|
return (rank[a] || 0) >= (rank[b] || 0) ? a : b
|
|
}
|
|
|
|
/**
|
|
* @deprecated Prefer mintCapability. Kept for PEARDOCK_LEGACY_INVITES=1.
|
|
*/
|
|
export function createInvite(opts = {}) {
|
|
if (!legacyInvitesEnabled()) {
|
|
// Bridge: mint capability and expose as token field for transitional clients
|
|
const cap = mintCapability(opts)
|
|
return {
|
|
token: cap.capability,
|
|
role: cap.role,
|
|
expiresAt: cap.expiresAt,
|
|
maxUses: cap.maxUses,
|
|
note: cap.note,
|
|
jti: cap.jti,
|
|
kind: 'capability',
|
|
redeemHint: cap.redeemHint,
|
|
}
|
|
}
|
|
|
|
const policy = loadPolicy()
|
|
const token = crypto.randomBytes(24).toString('hex')
|
|
const ttlHours = Math.min(Number(opts.ttlHours) || 72, 24 * 30)
|
|
const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString()
|
|
const invite = {
|
|
token,
|
|
role: opts.role || Roles.operator,
|
|
expiresAt,
|
|
maxUses: Math.min(Number(opts.maxUses) || 1, 100),
|
|
uses: 0,
|
|
note: opts.note || null,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
policy.invites[token] = invite
|
|
savePolicy(policy)
|
|
return {
|
|
token,
|
|
role: invite.role,
|
|
expiresAt: invite.expiresAt,
|
|
maxUses: invite.maxUses,
|
|
note: invite.note,
|
|
kind: 'legacy',
|
|
redeemHint: 'Pass inviteToken in handshake args within expiry',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redeem legacy invite token OR capability token.
|
|
* @param {string} token
|
|
* @param {string} peerIdHex
|
|
*/
|
|
export function redeemInvite(token, peerIdHex) {
|
|
if (!token) return null
|
|
const t = String(token)
|
|
|
|
// Capability tokens: base64url.body.mac
|
|
if (t.includes('.')) {
|
|
const { role, entry } = redeemCapability(t, peerIdHex)
|
|
return entry || registerPeer(peerIdHex, { role })
|
|
}
|
|
|
|
if (!legacyInvitesEnabled()) {
|
|
const err = new Error(
|
|
'Legacy invite tokens disabled. Use a pd1. invite or HMAC capability grant.'
|
|
)
|
|
err.code = 'INVITE_INVALID'
|
|
throw err
|
|
}
|
|
|
|
const policy = loadPolicy()
|
|
const invite = policy.invites[t]
|
|
if (!invite) {
|
|
const err = new Error('Invalid invite token')
|
|
err.code = 'INVITE_INVALID'
|
|
throw err
|
|
}
|
|
if (new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
const err = new Error('Invite token expired')
|
|
err.code = 'INVITE_INVALID'
|
|
throw err
|
|
}
|
|
if (invite.uses >= invite.maxUses) {
|
|
const err = new Error('Invite token already used')
|
|
err.code = 'INVITE_INVALID'
|
|
throw err
|
|
}
|
|
invite.uses += 1
|
|
if (invite.uses >= invite.maxUses) {
|
|
delete policy.invites[t]
|
|
} else {
|
|
policy.invites[t] = invite
|
|
}
|
|
savePolicy(policy)
|
|
return registerPeer(peerIdHex, { role: invite.role, note: `invite:${t.slice(0, 8)}` })
|
|
}
|
|
|
|
export function listPeers() {
|
|
const policy = loadPolicy()
|
|
return {
|
|
enforceAllowlist: enforceAllowlist(),
|
|
peers: Object.values(policy.peers),
|
|
revoked: policy.revoked,
|
|
}
|
|
}
|
|
|
|
export function listInvites() {
|
|
const policy = loadPolicy()
|
|
const now = Date.now()
|
|
const legacy = Object.values(policy.invites)
|
|
.filter((i) => new Date(i.expiresAt).getTime() >= now)
|
|
.map((i) => ({
|
|
token: i.token,
|
|
role: i.role,
|
|
expiresAt: i.expiresAt,
|
|
maxUses: i.maxUses,
|
|
uses: i.uses,
|
|
note: i.note,
|
|
createdAt: i.createdAt,
|
|
kind: 'legacy',
|
|
}))
|
|
const caps = Object.values(policy.capabilities)
|
|
.filter((c) => {
|
|
if (c.exp != null && c.exp < now) return false
|
|
if (c.maxUses > 0 && c.uses >= c.maxUses) return false
|
|
return true
|
|
})
|
|
.map((c) => ({
|
|
jti: c.jti,
|
|
role: c.role,
|
|
expiresAt: c.exp == null ? null : new Date(c.exp).toISOString(),
|
|
maxUses: c.maxUses,
|
|
uses: c.uses,
|
|
note: c.note,
|
|
createdAt: c.createdAt,
|
|
kind: 'capability',
|
|
persistent: c.exp == null && c.maxUses === 0,
|
|
// Do not re-emit full capability secret
|
|
token: `(capability ${c.jti.slice(0, 8)}…)`,
|
|
}))
|
|
return [...caps, ...legacy]
|
|
}
|
|
|
|
/**
|
|
* Revoke a capability jti (and/or remove a legacy invite token).
|
|
* Marks jti spent so HMAC tokens cannot be redeemed later.
|
|
* @param {{ jti?: string, token?: string }} opts
|
|
* @returns {{ success: boolean, deleted: string[], jti?: string|null }}
|
|
*/
|
|
export function deleteInvite(opts = {}) {
|
|
const policy = loadPolicy()
|
|
const deleted = []
|
|
let jti = opts.jti ? String(opts.jti).toLowerCase() : null
|
|
const token = opts.token ? String(opts.token) : null
|
|
|
|
// Capability body.mac → derive jti if possible
|
|
if (!jti && token && token.includes('.')) {
|
|
try {
|
|
const body = Buffer.from(
|
|
token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/'),
|
|
'base64'
|
|
).toString('utf8')
|
|
const payload = JSON.parse(body)
|
|
if (payload?.jti) jti = String(payload.jti).toLowerCase()
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
}
|
|
|
|
if (jti) {
|
|
if (policy.capabilities[jti]) {
|
|
delete policy.capabilities[jti]
|
|
deleted.push(`capability:${jti.slice(0, 8)}`)
|
|
}
|
|
if (!policy.spentJtis.includes(jti)) {
|
|
policy.spentJtis.push(jti)
|
|
deleted.push(`spent:${jti.slice(0, 8)}`)
|
|
}
|
|
}
|
|
|
|
if (token && policy.invites[token]) {
|
|
delete policy.invites[token]
|
|
deleted.push(`legacy:${token.slice(0, 8)}`)
|
|
}
|
|
|
|
// Legacy invite looked up by short prefix is not supported — require full token
|
|
|
|
if (deleted.length === 0 && !jti && !token) {
|
|
const err = new Error('jti or token required to delete invite')
|
|
err.code = 'INVALID_ARGS'
|
|
throw err
|
|
}
|
|
|
|
savePolicy(policy)
|
|
return { success: true, deleted, jti: jti || null }
|
|
}
|
|
|
|
export function policyPath() {
|
|
return policyFilePath()
|
|
}
|