/** * Connection invites for peardock (no Autopass / no RocksDB). * * Share string is always: * pd1. * * Auth is HMAC capability verified at handshake (shared/crypto-auth.js). */ import logger from '../utils/logger.js' import { mintCapability, deleteInvite as policyDeleteInvite } from './peer-policy.js' import { getServerPublicKeyHex } from './auth-keys.js' import { Roles } from '../../shared/protocol.js' import { encodePeardockInvite, decodePeardockInvite } from '../../shared/crypto-auth.js' const log = logger.child('invites') /** * @typedef {{ * invite: string, * role: string, * createdAt: string, * expiresAt: string|null, * jti: string, * maxUses: number, * note?: string|null, * capability: string, * persistent: boolean, * publicKeyHex: string, * }} InviteMeta */ /** @type {InviteMeta|null} */ let lastInviteMeta = null /** * Create a pd1 connection invite (operator/admin/viewer grant). * @param {{ role?: string, ttlHours?: number, maxUses?: number, note?: string, alias?: string, peerId?: string }} opts */ export function createConnectionInvite(opts = {}) { const publicKeyHex = getServerPublicKeyHex() if (!publicKeyHex || !/^[0-9a-f]{64}$/.test(publicKeyHex)) { throw new Error('Server public key not available for invite package') } const role = opts.role || Roles.operator const cap = mintCapability({ role, ttlHours: opts.ttlHours, maxUses: opts.maxUses, note: opts.note, peerId: opts.peerId || null, }) const share = encodePeardockInvite({ publicKeyHex, capability: cap.capability, role: cap.role, jti: cap.jti, alias: opts.alias || null, expiresAt: cap.expiresAt, }) lastInviteMeta = { invite: share, role: cap.role, createdAt: new Date().toISOString(), expiresAt: cap.expiresAt, jti: cap.jti, maxUses: cap.maxUses, note: opts.note || null, capability: cap.capability, persistent: cap.persistent, publicKeyHex, } log.info('Created connection invite', { role: cap.role, jti: cap.jti.slice(0, 8), expiresAt: cap.expiresAt, maxUses: cap.maxUses, inviteLen: share.length, format: 'pd1', }) return { kind: 'pd1', invite: share, token: share, share, role: cap.role, expiresAt: cap.expiresAt, maxUses: cap.maxUses, jti: cap.jti, note: opts.note || null, publicKeyHex, persistent: cap.persistent, capability: cap.capability, redeemHint: 'Share this peardock invite (starts with pd1.). Paste the full string in Add peer. Do not share SERVER_SEED.', } } /** * Active invite list for Access UI (single in-memory slot; capability mint is in peer-policy). */ export function listConnectionInvites() { if (!lastInviteMeta) return [] if ( lastInviteMeta.expiresAt && new Date(lastInviteMeta.expiresAt).getTime() < Date.now() ) { return [] } return [ { kind: 'pd1', invite: lastInviteMeta.invite, token: lastInviteMeta.invite, share: lastInviteMeta.invite, role: lastInviteMeta.role, expiresAt: lastInviteMeta.expiresAt, jti: lastInviteMeta.jti, maxUses: lastInviteMeta.maxUses ?? 0, uses: 0, note: lastInviteMeta.note, createdAt: lastInviteMeta.createdAt, publicKeyHex: lastInviteMeta.publicKeyHex || null, persistent: lastInviteMeta.persistent ?? (lastInviteMeta.expiresAt == null && (lastInviteMeta.maxUses ?? 0) === 0), capability: lastInviteMeta.capability || null, grant: { kind: 'capability', jti: lastInviteMeta.jti, role: lastInviteMeta.role, expiresAt: lastInviteMeta.expiresAt, maxUses: lastInviteMeta.maxUses ?? 0, uses: 0, persistent: lastInviteMeta.persistent, embeddedInInvite: true, }, }, ] } /** * Delete the active invite and spend its capability jti. * @param {{ jti?: string|null, token?: string|null }} [opts] * @returns {{ success: boolean, jti: string|null, deleted: string[] }} */ export function deleteConnectionInvite(opts = {}) { const deleted = [] let jti = opts.jti || lastInviteMeta?.jti || null // Decode pd1 share string if provided if (!jti && opts.token) { const pkg = decodePeardockInvite(opts.token) if (pkg?.jti) jti = pkg.jti } if (jti && jti !== 'unknown') { try { const r = policyDeleteInvite({ jti }) for (const d of r.deleted || []) deleted.push(d) } catch (err) { log.debug('policy deleteInvite', { error: err.message }) } } if (lastInviteMeta) { deleted.push('pd1') lastInviteMeta = null } log.info('Deleted connection invite', { jti: jti ? String(jti).slice(0, 8) : null, }) return { success: deleted.length > 0, jti: jti && jti !== 'unknown' ? jti : null, deleted, } } /** Clear in-memory invite slot (server shutdown). */ export async function closeConnectionInvites() { lastInviteMeta = null }