Updates
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-14 20:25:17 -04:00
parent bd460913bf
commit cb0b59f5c3
9 changed files with 211 additions and 12 deletions
+58 -7
View File
@@ -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 =
+4
View File
@@ -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 })
},
+1
View File
@@ -40,6 +40,7 @@ const AUDIT_METHODS = new Set([
'vaultStoreCredential',
'vaultDeleteCredential',
'invitePeer',
'deleteInvite',
'revokePeer',
'setPeerRole',
'archiveContainerPut',
+54
View File
@@ -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()
}
+66
View File
@@ -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')
+2
View File
@@ -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',
+6
View File
@@ -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 },
+3
View File
@@ -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))
})
+12
View File
@@ -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))
})