@@ -1321,37 +1321,54 @@ async function loadAccessView() {
|
||||
invitesEl.innerHTML = data
|
||||
.map((i, idx) => {
|
||||
// Full invite / capability string — never slice or ellipsis (truncation breaks AutoPass)
|
||||
const token = String(i.invite || i.token || i.jti || '');
|
||||
const token = String(i.invite || i.token || '');
|
||||
const jti = i.jti ? String(i.jti) : '';
|
||||
const kind = i.kind || 'invite';
|
||||
const meta = [
|
||||
`role=${i.role || '—'}`,
|
||||
i.expiresAt ? `exp=${i.expiresAt}` : null,
|
||||
i.maxUses != null ? `uses=${i.uses ?? 0}/${i.maxUses}` : null,
|
||||
token ? `${token.length} chars` : null,
|
||||
jti ? `jti=${jti.slice(0, 8)}…` : null,
|
||||
token && !token.startsWith('(capability ')
|
||||
? `${token.length} chars`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const canCopy = Boolean(token) && !token.startsWith('(capability ');
|
||||
return `<div class="access-invite-card mb-2" data-invite-idx="${idx}">
|
||||
const displayBody = canCopy
|
||||
? token
|
||||
: jti
|
||||
? `(capability ${jti})`
|
||||
: token || '—';
|
||||
return `<div class="access-invite-card mb-2" data-invite-idx="${idx}" data-kind="${escapeHtmlLite(kind)}" data-jti="${escapeHtmlLite(jti)}" data-token="${canCopy ? escapeHtmlLite(token) : ''}">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||
<span class="badge bg-secondary">${escapeHtmlLite(kind)}</span>
|
||||
<span class="small text-muted">${escapeHtmlLite(meta)}</span>
|
||||
<div class="ms-auto d-flex gap-1">
|
||||
${
|
||||
canCopy
|
||||
? `<button type="button" class="btn btn-sm btn-primary ms-auto access-invite-copy" title="Copy full invite">
|
||||
<i class="fas fa-copy me-1"></i>Copy invite
|
||||
? `<button type="button" class="btn btn-sm btn-primary access-invite-copy" title="Copy full invite">
|
||||
<i class="fas fa-copy me-1"></i>Copy
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger access-invite-delete" data-min-role="admin" title="Delete invite">
|
||||
<i class="fas fa-trash me-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
<pre class="invite-secret-full mb-0" spellcheck="false">${escapeHtmlLite(token || '—')}</pre>
|
||||
</div>
|
||||
<pre class="invite-secret-full mb-0" spellcheck="false">${escapeHtmlLite(displayBody)}</pre>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
invitesEl.querySelectorAll('.access-invite-copy').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const card = btn.closest('.access-invite-card');
|
||||
const full = card?.querySelector('.invite-secret-full')?.textContent || '';
|
||||
const full =
|
||||
card?.dataset?.token ||
|
||||
card?.querySelector('.invite-secret-full')?.textContent ||
|
||||
'';
|
||||
if (!full || full === '—') return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(full);
|
||||
@@ -1376,6 +1393,40 @@ async function loadAccessView() {
|
||||
}
|
||||
});
|
||||
});
|
||||
invitesEl.querySelectorAll('.access-invite-delete').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const card = btn.closest('.access-invite-card');
|
||||
if (!card) return;
|
||||
const kind = card.dataset.kind || undefined;
|
||||
const jti = card.dataset.jti || undefined;
|
||||
const token = card.dataset.token || undefined;
|
||||
const ok = window.peardockOps?.confirmDestructive
|
||||
? await window.peardockOps.confirmDestructive(
|
||||
'Delete invite',
|
||||
'Delete this invite? It can no longer be redeemed. Existing connected peers keep their role until revoked.'
|
||||
)
|
||||
: typeof confirm === 'function'
|
||||
? confirm('Delete this invite? It can no longer be redeemed.')
|
||||
: true;
|
||||
if (!ok) return;
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await manager.request(Methods.deleteInvite, {
|
||||
kind: kind || undefined,
|
||||
jti: jti || undefined,
|
||||
token: token || undefined,
|
||||
});
|
||||
if (typeof showAlert === 'function') showAlert('success', 'Invite deleted');
|
||||
loadAccessView();
|
||||
} catch (err) {
|
||||
if (typeof showAlert === 'function') {
|
||||
showAlert('danger', err.message || 'Failed to delete invite');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
applyRoleUI();
|
||||
}
|
||||
} catch (err) {
|
||||
invitesEl.textContent =
|
||||
|
||||
@@ -392,6 +392,10 @@ export const api = {
|
||||
return connOrActive(connection).request(Methods.invitePeer, args)
|
||||
},
|
||||
|
||||
deleteInvite(args = {}, connection) {
|
||||
return connOrActive(connection).request(Methods.deleteInvite, args)
|
||||
},
|
||||
|
||||
revokePeer(peerId, connection) {
|
||||
return connOrActive(connection).request(Methods.revokePeer, { peerId })
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ const AUDIT_METHODS = new Set([
|
||||
'vaultStoreCredential',
|
||||
'vaultDeleteCredential',
|
||||
'invitePeer',
|
||||
'deleteInvite',
|
||||
'revokePeer',
|
||||
'setPeerRole',
|
||||
'archiveContainerPut',
|
||||
|
||||
@@ -458,6 +458,60 @@ export function listInvites() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -113,6 +113,72 @@ export function registerPeerHandlers(session) {
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('deleteInvite', async (args) => {
|
||||
const kind = args.kind ? String(args.kind) : null
|
||||
const jti = args.jti ? validation.sanitizeString(String(args.jti), 64).toLowerCase() : null
|
||||
const tokenStr =
|
||||
args.token != null
|
||||
? String(args.token)
|
||||
: args.invite != null
|
||||
? String(args.invite)
|
||||
: null
|
||||
|
||||
if (!kind && !jti && !tokenStr) {
|
||||
const err = new Error('Provide kind, jti, or token to delete an invite')
|
||||
err.code = 'INVALID_ARGS'
|
||||
throw err
|
||||
}
|
||||
|
||||
const deleted = []
|
||||
let active = []
|
||||
try {
|
||||
active = autopassVault.listAutopassInvites()
|
||||
} catch {
|
||||
active = []
|
||||
}
|
||||
|
||||
const matchesAutopass =
|
||||
kind === 'autopass' ||
|
||||
(jti && active.some((i) => i.jti === jti)) ||
|
||||
(tokenStr && active.some((i) => i.invite === tokenStr || i.token === tokenStr))
|
||||
|
||||
if (matchesAutopass) {
|
||||
// Invalidate the capability grant bound to this AutoPass package
|
||||
const spendJti = jti || active[0]?.jti || null
|
||||
if (spendJti) {
|
||||
const r = peerPolicy.deleteInvite({ jti: spendJti })
|
||||
for (const d of r.deleted || []) {
|
||||
if (!deleted.includes(d)) deleted.push(d)
|
||||
}
|
||||
}
|
||||
await autopassVault.deleteAutopassInvite()
|
||||
if (!deleted.includes('autopass')) deleted.push('autopass')
|
||||
}
|
||||
|
||||
// Capability HMAC or legacy random token (policy file)
|
||||
if (jti || tokenStr) {
|
||||
const r = peerPolicy.deleteInvite({
|
||||
jti: jti || undefined,
|
||||
token: tokenStr || undefined,
|
||||
})
|
||||
for (const d of r.deleted || []) {
|
||||
if (!deleted.includes(d)) deleted.push(d)
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted.length) {
|
||||
const err = new Error('Invite not found')
|
||||
err.code = 'INVITE_INVALID'
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'inviteDeleted',
|
||||
deleted,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('revokePeer', async (args) => {
|
||||
const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase()
|
||||
if (!peerId) throw new Error('peerId required')
|
||||
|
||||
@@ -156,6 +156,7 @@ export const MethodRoles = Object.freeze({
|
||||
vaultStoreCredential: Roles.admin,
|
||||
vaultDeleteCredential: Roles.admin,
|
||||
invitePeer: Roles.admin,
|
||||
deleteInvite: Roles.admin,
|
||||
revokePeer: Roles.admin,
|
||||
unrevokePeer: Roles.admin,
|
||||
setPeerRole: Roles.admin,
|
||||
@@ -300,6 +301,7 @@ export const Methods = Object.freeze({
|
||||
// Peer ACL
|
||||
listPeers: 'listPeers',
|
||||
invitePeer: 'invitePeer',
|
||||
deleteInvite: 'deleteInvite',
|
||||
revokePeer: 'revokePeer',
|
||||
unrevokePeer: 'unrevokePeer',
|
||||
setPeerRole: 'setPeerRole',
|
||||
|
||||
@@ -50,6 +50,12 @@ export const MethodSchemas = Object.freeze({
|
||||
ttlHours: { type: 'number', required: false, min: 1, max: 720 },
|
||||
maxUses: { type: 'number', required: false, min: 1, max: 100 },
|
||||
},
|
||||
deleteInvite: {
|
||||
kind: { type: 'string', required: false, maxLen: 32 },
|
||||
jti: { type: 'string', required: false, maxLen: 64 },
|
||||
token: { type: 'string', required: false, maxLen: 4096 },
|
||||
invite: { type: 'string', required: false, maxLen: 4096 },
|
||||
},
|
||||
revokePeer: {
|
||||
peerId: { type: 'string', required: false, maxLen: 64 },
|
||||
id: { type: 'string', required: false, maxLen: 64 },
|
||||
|
||||
@@ -102,8 +102,11 @@ test('viewer can search images (read-only)', (t) => {
|
||||
test('invites are admin-only (not viewer or operator)', (t) => {
|
||||
t.ok(roleAllows(Roles.admin, Methods.invitePeer))
|
||||
t.ok(roleAllows(Roles.admin, Methods.listInvites))
|
||||
t.ok(roleAllows(Roles.admin, Methods.deleteInvite))
|
||||
t.absent(roleAllows(Roles.viewer, Methods.invitePeer))
|
||||
t.absent(roleAllows(Roles.viewer, Methods.listInvites))
|
||||
t.absent(roleAllows(Roles.viewer, Methods.deleteInvite))
|
||||
t.absent(roleAllows(Roles.operator, Methods.invitePeer))
|
||||
t.absent(roleAllows(Roles.operator, Methods.listInvites))
|
||||
t.absent(roleAllows(Roles.operator, Methods.deleteInvite))
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
redeemInvite,
|
||||
redeemCapability,
|
||||
mintCapability,
|
||||
deleteInvite,
|
||||
revokePeer,
|
||||
isPeerAllowed,
|
||||
isPeerRevoked,
|
||||
@@ -93,3 +94,14 @@ test('seed/capability auth modes bypass allowlist', (t) => {
|
||||
t.ok(isPeerAllowed(unknown, { authMode: 'capability' }))
|
||||
delete process.env.PEARDOCK_PEER_ALLOWLIST
|
||||
})
|
||||
|
||||
test('deleteInvite revokes capability jti so redeem fails', (t) => {
|
||||
const peerD = '55'.repeat(32)
|
||||
const cap = mintCapability({ role: 'operator', maxUses: 5, ttlHours: 1 })
|
||||
t.ok(listInvites().some((i) => i.jti === cap.jti))
|
||||
const res = deleteInvite({ jti: cap.jti })
|
||||
t.ok(res.success)
|
||||
t.ok(res.deleted.length > 0)
|
||||
t.absent(listInvites().some((i) => i.jti === cap.jti))
|
||||
t.exception(() => redeemCapability(cap.capability, peerD))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user