Files
peardock/server/core/connection-invites.js
T

190 lines
5.0 KiB
JavaScript

/**
* Connection invites for peardock (no Autopass / no RocksDB).
*
* Share string is always:
* pd1.<base64url JSON { publicKeyHex, capability, role, jti, ... }>
*
* 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
}