190 lines
5.0 KiB
JavaScript
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
|
|
}
|