feat(signer): permissions UI — declared grants surfaced, always-allow kind-scoped

Step 4 groundwork, the enforcement half that was invisible or too broad:

- Nip46Status now carries the connection's declared perms= grant list and
  its expiry; Signer Mode shows a Permissions panel on a live session
  (explicit grant rows, or a plain statement that the signer app approves
  each request when no list was declared).
- 'Always allow' grants are kind-scoped: a sign_event grant records the
  kind of the request the user actually approved and never covers other
  kinds. Legacy kind-less grants keep their all-kinds meaning so existing
  vaults keep working. Enforced in both bunker.rs and nip46_client.rs.
- Grants list on the Signer screen renders human labels with kind scope.

Tests: vault kind-scoping unit tests, frontend permission-label unit
tests + two SignerModeScreen tests (declared list, signer-side note).
This commit is contained in:
Avi 2026-09-27 22:37:54 -05:00
commit adbc7c2d75
11 changed files with 350 additions and 31 deletions

View file

@ -0,0 +1,52 @@
import type { Nip46Permissions, SignerGrant } from './types';
/** Human label for a NIP-46 method name. */
export function methodLabel(method: string): string {
switch (method) {
case 'sign_event':
return 'Sign events';
case 'nip44_encrypt':
return 'Encrypt messages (NIP-44)';
case 'nip44_decrypt':
return 'Decrypt messages (NIP-44)';
case 'get_public_key':
return 'Read your public key';
case 'get_relays':
return 'Read your relay list';
default:
return method;
}
}
/** One-line description of a permission grant, e.g.
* "Sign events (kinds 1, 30023)" or "Sign events (all kinds)". */
export function permissionLabel(method: string, allowedKinds?: number[]): string {
const base = methodLabel(method);
if (method === 'sign_event') {
if (!allowedKinds || allowedKinds.length === 0) return `${base} — all kinds`;
return `${base} — kinds ${allowedKinds.join(', ')}`;
}
return base;
}
/** Grant rows for the "always allow" list. */
export function grantLabel(grant: SignerGrant): string {
return permissionLabel(grant.method, grant.allowed_kinds);
}
/** Rows for the declared per-connection permission set. An absent set means
* there is no local grant list — the signer app approves each request. */
export function declaredPermissionRows(permissions?: Nip46Permissions): string[] | null {
if (!permissions) return null;
const granted = permissions.granted ?? [];
if (granted.length === 0) return [];
return granted.map((p) => permissionLabel(p.method, p.allowed_kinds));
}
/** Format a connection expiry for display. */
export function formatExpiry(expiresAt?: number): string | null {
if (!expiresAt) return null;
const date = new Date(expiresAt * 1000);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}

View file

@ -29,6 +29,19 @@ export interface PendingApproval {
details?: ApprovalDetails;
}
/** A single granted NIP-46 permission (mirrors the Rust Nip46Permission). */
export interface Nip46Permission {
/** The NIP-46 method this covers, e.g. `sign_event`. */
method: string;
/** Event-kind restrictions for `sign_event`; empty = all kinds. */
allowed_kinds?: number[];
}
/** Declared per-connection permission set (from a `perms=` connect URI). */
export interface Nip46Permissions {
granted?: Nip46Permission[];
}
/** A standing "always allow" grant: one app may use one method without a
* prompt. Created by choosing "Always allow" on an approval; revoked from
* the Signer screen. */
@ -37,6 +50,8 @@ export interface SignerGrant {
app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string;
/** Event kinds covered for `sign_event`; empty = all kinds (legacy). */
allowed_kinds?: number[];
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -75,6 +90,11 @@ export interface Nip46SignerStatus {
pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string;
/** Declared per-connection permissions, when the connect URI carried a
* `perms=` grant list. Absent = the signer app enforces via its prompts. */
permissions?: Nip46Permissions;
/** Unix timestamp when the connection expires, if it has a deadline. */
expires_at?: number;
}
/** Union of all signer statuses. */

View file

@ -5,6 +5,7 @@ import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { declaredPermissionRows, formatExpiry } from '../lib/permissions';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
@ -528,6 +529,40 @@ export function SignerModeScreen() {
via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays
</p>
<div className="signer-permissions">
<h3>Permissions</h3>
{(() => {
const rows = declaredPermissionRows(nip46StatusState.permissions);
const expiry = formatExpiry(nip46StatusState.expires_at);
return (
<>
{rows === null ? (
<p className="hint">
This signer approves every request on your phone — Keynctr holds no
standing permission list for this connection.
</p>
) : rows.length === 0 ? (
<p className="hint">
No operations were granted by the connect request.
</p>
) : (
<ul className="signer-permission-list">
{rows.map((row) => (
<li key={row} className="mono">
{row}
</li>
))}
</ul>
)}
{expiry && (
<p className="hint">
<Icon name="shield" size={14} /> This connection expires {expiry}.
</p>
)}
</>
);
})()}
</div>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}

View file

@ -5,6 +5,7 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format';
import { grantLabel } from '../lib/permissions';
import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
@ -252,7 +253,7 @@ export function SignerScreen() {
{grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{grant.method}</code>
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
<p>for {shortHexId(grant.app_pubkey)}</p>
</div>
<div className="settings-inline">

View file

@ -2242,6 +2242,26 @@ select {
gap: 12px;
}
.signer-permissions {
width: 100%;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.signer-permissions h3 {
margin: 0 0 6px;
font-size: 13px;
}
.signer-permission-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.7;
}
/* -------------------------------------------------------------------------
Motion system
Purposeful motion for feedback, state, and continuity.

View file

@ -69,4 +69,40 @@ describe('SignerModeScreen handshake states', () => {
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true),
);
});
it('shows the declared permission list on a connected session', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: true,
signer_pubkey: 'aabbccddeeff0011',
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
permissions: {
granted: [{ method: 'sign_event', allowed_kinds: [1, 30023] }, { method: 'nip44_encrypt' }],
},
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Sign events — kinds 1, 30023')).toBeInTheDocument();
expect(screen.getByText('Encrypt messages (NIP-44)')).toBeInTheDocument();
});
it('explains signer-side enforcement when no grant list was declared', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: true,
signer_pubkey: 'aabbccddeeff0011',
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(await screen.findByText(/approves every request on your phone/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
declaredPermissionRows,
formatExpiry,
grantLabel,
permissionLabel,
} from '../lib/permissions';
describe('permission labels', () => {
it('labels a kind-scoped sign_event grant', () => {
expect(permissionLabel('sign_event', [1, 30023])).toBe('Sign events — kinds 1, 30023');
});
it('labels an all-kinds sign_event grant', () => {
expect(permissionLabel('sign_event', [])).toBe('Sign events — all kinds');
expect(permissionLabel('sign_event')).toBe('Sign events — all kinds');
});
it('labels non-signing methods without kind noise', () => {
expect(permissionLabel('nip44_decrypt')).toBe('Decrypt messages (NIP-44)');
});
it('renders a grant row through grantLabel', () => {
expect(grantLabel({ app_pubkey: 'aa', method: 'sign_event', allowed_kinds: [1] })).toBe(
'Sign events — kinds 1',
);
});
});
describe('declared permission rows', () => {
it('returns null when the connection declared no grant list', () => {
expect(declaredPermissionRows(undefined)).toBeNull();
expect(declaredPermissionRows({})).toEqual([]);
});
it('renders each granted method', () => {
expect(
declaredPermissionRows({
granted: [{ method: 'sign_event', allowed_kinds: [1] }, { method: 'nip44_encrypt' }],
}),
).toEqual(['Sign events — kinds 1', 'Encrypt messages (NIP-44)']);
});
});
describe('formatExpiry', () => {
it('formats a unix timestamp', () => {
expect(formatExpiry(1760000000)).toBeTruthy();
});
it('returns null when there is no deadline', () => {
expect(formatExpiry(undefined)).toBeNull();
});
});