@@ -1321,37 +1321,54 @@ async function loadAccessView() {
|
|||||||
invitesEl.innerHTML = data
|
invitesEl.innerHTML = data
|
||||||
.map((i, idx) => {
|
.map((i, idx) => {
|
||||||
// Full invite / capability string — never slice or ellipsis (truncation breaks AutoPass)
|
// 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 kind = i.kind || 'invite';
|
||||||
const meta = [
|
const meta = [
|
||||||
`role=${i.role || '—'}`,
|
`role=${i.role || '—'}`,
|
||||||
i.expiresAt ? `exp=${i.expiresAt}` : null,
|
i.expiresAt ? `exp=${i.expiresAt}` : null,
|
||||||
i.maxUses != null ? `uses=${i.uses ?? 0}/${i.maxUses}` : 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)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
const canCopy = Boolean(token) && !token.startsWith('(capability ');
|
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">
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||||
<span class="badge bg-secondary">${escapeHtmlLite(kind)}</span>
|
<span class="badge bg-secondary">${escapeHtmlLite(kind)}</span>
|
||||||
<span class="small text-muted">${escapeHtmlLite(meta)}</span>
|
<span class="small text-muted">${escapeHtmlLite(meta)}</span>
|
||||||
|
<div class="ms-auto d-flex gap-1">
|
||||||
${
|
${
|
||||||
canCopy
|
canCopy
|
||||||
? `<button type="button" class="btn btn-sm btn-primary ms-auto access-invite-copy" title="Copy full 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 invite
|
<i class="fas fa-copy me-1"></i>Copy
|
||||||
</button>`
|
</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>
|
</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>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
invitesEl.querySelectorAll('.access-invite-copy').forEach((btn) => {
|
invitesEl.querySelectorAll('.access-invite-copy').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const card = btn.closest('.access-invite-card');
|
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;
|
if (!full || full === '—') return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(full);
|
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) {
|
} catch (err) {
|
||||||
invitesEl.textContent =
|
invitesEl.textContent =
|
||||||
|
|||||||
@@ -392,6 +392,10 @@ export const api = {
|
|||||||
return connOrActive(connection).request(Methods.invitePeer, args)
|
return connOrActive(connection).request(Methods.invitePeer, args)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteInvite(args = {}, connection) {
|
||||||
|
return connOrActive(connection).request(Methods.deleteInvite, args)
|
||||||
|
},
|
||||||
|
|
||||||
revokePeer(peerId, connection) {
|
revokePeer(peerId, connection) {
|
||||||
return connOrActive(connection).request(Methods.revokePeer, { peerId })
|
return connOrActive(connection).request(Methods.revokePeer, { peerId })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const AUDIT_METHODS = new Set([
|
|||||||
'vaultStoreCredential',
|
'vaultStoreCredential',
|
||||||
'vaultDeleteCredential',
|
'vaultDeleteCredential',
|
||||||
'invitePeer',
|
'invitePeer',
|
||||||
|
'deleteInvite',
|
||||||
'revokePeer',
|
'revokePeer',
|
||||||
'setPeerRole',
|
'setPeerRole',
|
||||||
'archiveContainerPut',
|
'archiveContainerPut',
|
||||||
|
|||||||
@@ -458,6 +458,60 @@ export function listInvites() {
|
|||||||
return [...caps, ...legacy]
|
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() {
|
export function policyPath() {
|
||||||
return policyFilePath()
|
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) => {
|
session.respond('revokePeer', async (args) => {
|
||||||
const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase()
|
const peerId = validation.sanitizeString(args.peerId || args.id, 64).toLowerCase()
|
||||||
if (!peerId) throw new Error('peerId required')
|
if (!peerId) throw new Error('peerId required')
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const MethodRoles = Object.freeze({
|
|||||||
vaultStoreCredential: Roles.admin,
|
vaultStoreCredential: Roles.admin,
|
||||||
vaultDeleteCredential: Roles.admin,
|
vaultDeleteCredential: Roles.admin,
|
||||||
invitePeer: Roles.admin,
|
invitePeer: Roles.admin,
|
||||||
|
deleteInvite: Roles.admin,
|
||||||
revokePeer: Roles.admin,
|
revokePeer: Roles.admin,
|
||||||
unrevokePeer: Roles.admin,
|
unrevokePeer: Roles.admin,
|
||||||
setPeerRole: Roles.admin,
|
setPeerRole: Roles.admin,
|
||||||
@@ -300,6 +301,7 @@ export const Methods = Object.freeze({
|
|||||||
// Peer ACL
|
// Peer ACL
|
||||||
listPeers: 'listPeers',
|
listPeers: 'listPeers',
|
||||||
invitePeer: 'invitePeer',
|
invitePeer: 'invitePeer',
|
||||||
|
deleteInvite: 'deleteInvite',
|
||||||
revokePeer: 'revokePeer',
|
revokePeer: 'revokePeer',
|
||||||
unrevokePeer: 'unrevokePeer',
|
unrevokePeer: 'unrevokePeer',
|
||||||
setPeerRole: 'setPeerRole',
|
setPeerRole: 'setPeerRole',
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export const MethodSchemas = Object.freeze({
|
|||||||
ttlHours: { type: 'number', required: false, min: 1, max: 720 },
|
ttlHours: { type: 'number', required: false, min: 1, max: 720 },
|
||||||
maxUses: { type: 'number', required: false, min: 1, max: 100 },
|
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: {
|
revokePeer: {
|
||||||
peerId: { type: 'string', required: false, maxLen: 64 },
|
peerId: { type: 'string', required: false, maxLen: 64 },
|
||||||
id: { 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) => {
|
test('invites are admin-only (not viewer or operator)', (t) => {
|
||||||
t.ok(roleAllows(Roles.admin, Methods.invitePeer))
|
t.ok(roleAllows(Roles.admin, Methods.invitePeer))
|
||||||
t.ok(roleAllows(Roles.admin, Methods.listInvites))
|
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.invitePeer))
|
||||||
t.absent(roleAllows(Roles.viewer, Methods.listInvites))
|
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.invitePeer))
|
||||||
t.absent(roleAllows(Roles.operator, Methods.listInvites))
|
t.absent(roleAllows(Roles.operator, Methods.listInvites))
|
||||||
|
t.absent(roleAllows(Roles.operator, Methods.deleteInvite))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
redeemInvite,
|
redeemInvite,
|
||||||
redeemCapability,
|
redeemCapability,
|
||||||
mintCapability,
|
mintCapability,
|
||||||
|
deleteInvite,
|
||||||
revokePeer,
|
revokePeer,
|
||||||
isPeerAllowed,
|
isPeerAllowed,
|
||||||
isPeerRevoked,
|
isPeerRevoked,
|
||||||
@@ -93,3 +94,14 @@ test('seed/capability auth modes bypass allowlist', (t) => {
|
|||||||
t.ok(isPeerAllowed(unknown, { authMode: 'capability' }))
|
t.ok(isPeerAllowed(unknown, { authMode: 'capability' }))
|
||||||
delete process.env.PEARDOCK_PEER_ALLOWLIST
|
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