diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md
index 3691a54..8c5ac00 100644
--- a/CHECKPOINT-encryption.md
+++ b/CHECKPOINT-encryption.md
@@ -1,3 +1,64 @@
+# Checkpoint — Step 4 finished: interactive kind scope at approval (2026-10-01)
+
+## Where things are
+- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`d09c4ec`**
+ ("feat(signer): interactive kind scope in the approval prompt (Step 4 finish)").
+ Previous: `8ffeb42` + `d1622a0` (checkpoint updates, pushed), `83f5940`
+ (placeholder kind-0 fix), `e8d2abb` (grant kind-editing UI).
+- Working tree: clean for tracked files (always-untracked: COSMIC_THEME.md,
+ icon jpeg, deferred/). Push status in Next steps.
+- Release binary rebuilt from d09c4ec at 13:30 (real 30s build, mtime verified).
+
+## What was completed (user-facing)
+**Step 4 remainder — kind scope chosen AT approval time:**
+1. Signer screen and Signer Mode screen: on a pending `sign_event`
+ request, "Always allow…" opens an inline kind editor prefilled with the
+ request's own event kind; Allow records the grant with exactly the
+ edited kinds (sorted+deduped, empty = all kinds = explicit broadening),
+ Cancel leaves the request pending. Non-signing methods keep the plain
+ "Always allow" button (no kind dimension).
+2. Backend: `Nip46Approve`/`SignerApprove` gained `grant_kinds`
+ (`serde(default) Option>` — old payloads stay valid).
+ `respond_to_approval_with_always(.., grant_kinds)`: `Some(list)` stores
+ the edited scope verbatim; `None` keeps the old fallback (kind of the
+ request being approved). Legacy vault rows untouched.
+3. `nip46_status_as_bunker_json` now includes each pending request's
+ `details`, so the legacy Signer screen can prefill the editor too.
+4. Shared `parseKindsInput()` in `lib/permissions.ts` (used by both the
+ approval editor and the existing grant editor); grant editor refactored
+ onto it — behaviour unchanged.
+5. Latent bug fixed: `AppProvider.signerApprove` dropped the `always`
+ argument, so the legacy "Always allow" button never recorded a grant.
+
+## Commits this session (newest first)
+- `d09c4ec` feat(signer): interactive kind scope in the approval prompt (Step 4 finish)
+- `8ffeb42` docs(checkpoint): 8070dfc..d1622a0 pushed to origin/master
+- (earlier today) `d1622a0`, `83f5940`, `e8d2abb` — see checkpoint below
+
+## Verification (all green, 2026-10-01)
+- `cargo test` → 227 unit + 6 e2e, 0 failed · `cargo clippy --all-targets` → 0 warnings
+- `cargo fmt --check` clean · `cargo build --release` rebuilt 13:30 from d09c4ec
+- frontend: vitest 148/19 files (6 new: 3 approval-editor tests, 3
+ parseKindsInput units); `typecheck`, `lint`, `format:check`, `build`,
+ `electron:build` all green.
+
+## How to verify in the app
+1. Fully quit and relaunch Keynctr (new backend, 13:30).
+2. Have the connected app request a signature → Signer screen →
+ "Always allow…" on the pending request → edit kinds → Allow.
+ The "Always-allow permissions" card shows the scoped grant; a later
+ request of an UNcovered kind prompts again.
+3. e2e-mechanics are covered by the 3 new SignerScreen tests.
+
+## Next steps
+- PUSHED 2026-10-01 after this checkpoint (see git remote readback);
+ token per-use, not stored (revoke when convenient).
+- User live pass: relaunch, rename npub1p437… + Publish name, try the new
+ approval-time kind editor with Amber.
+- Optional: remove /tmp/kn-base worktree when done.
+
+---
+
# Checkpoint — placeholder kind-0 fix + grant editing UI (2026-10-01)
## Where things are
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 4aa17f7..6b2de5b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -127,15 +127,25 @@ export const api = {
nip46Disconnect: () => call('nip46_disconnect'),
nip46CancelPairing: () => call('nip46_cancel_pairing'),
nip46Status: () => call('nip46_status'),
- nip46Approve: (id: string, approved: boolean, always = false) =>
- call('nip46_approve', { id, approved, always }),
+ nip46Approve: (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
+ call('nip46_approve', {
+ id,
+ approved,
+ always,
+ grant_kinds: grantKinds ?? null,
+ }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call('signer_connect', { uri }),
signerDisconnect: () => call('signer_disconnect'),
signerStatus: () => call('signer_status'),
- signerApprove: (id: string, approved: boolean, always = false) =>
- call('signer_approve', { id, approved, always }),
+ signerApprove: (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
+ call('signer_approve', {
+ id,
+ approved,
+ always,
+ grant_kinds: grantKinds ?? null,
+ }),
// Standing "always allow" grants for apps using us as their signer.
signerGrantsList: () => call('signer_grants_list'),
diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts
index aed1d04..814c54a 100644
--- a/frontend/src/lib/permissions.ts
+++ b/frontend/src/lib/permissions.ts
@@ -50,3 +50,20 @@ export function formatExpiry(expiresAt?: number): string | null {
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}
+
+/** Parse a comma-separated event-kind list typed by the user.
+ * Returns the kinds (sorted, de-duplicated) or an error naming the first
+ * non-numeric token. An EMPTY input yields an empty list — the explicit
+ * "all kinds" broadening, matching the backend's representation. */
+export type KindsParseResult = { ok: true; kinds: number[] } | { ok: false; error: string };
+
+export function parseKindsInput(input: string): KindsParseResult {
+ const parts = input
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const bad = parts.find((s) => !/^\d+$/.test(s));
+ if (bad !== undefined) return { ok: false, error: `“${bad}” is not an event kind number.` };
+ const kinds = [...new Set(parts.map(Number))].sort((a, b) => a - b);
+ return { ok: true, kinds };
+}
diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx
index 51a46c8..f15f7dd 100644
--- a/frontend/src/screens/SignerModeScreen.tsx
+++ b/frontend/src/screens/SignerModeScreen.tsx
@@ -5,8 +5,13 @@ 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 { declaredPermissionRows, formatExpiry, parseKindsInput } from '../lib/permissions';
+import type {
+ SignerMode,
+ EmbeddedSignerStatus,
+ Nip46SignerStatus,
+ PendingApproval,
+} from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SignerModeScreen() {
@@ -232,10 +237,10 @@ export function SignerModeScreen() {
);
const handleNip46Approve = useCallback(
- async (id: string, approved: boolean, always = false) => {
+ async (id: string, approved: boolean, always = false, grantKinds?: number[]) => {
setError(null);
try {
- const status = await nip46Approve(id, approved, always);
+ const status = await nip46Approve(id, approved, always, grantKinds);
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -244,6 +249,28 @@ export function SignerModeScreen() {
[nip46Approve],
);
+ // Interactive kind scope at approval time (same pattern as SignerScreen):
+ // "Always allow…" on a sign_event request opens a kind editor prefilled
+ // with the request's own kind before the grant is recorded.
+ const [alwaysDraft, setAlwaysDraft] = useState<{ id: string; kinds: string } | null>(null);
+ const [alwaysError, setAlwaysError] = useState(null);
+
+ const startAlwaysAllow = (request: PendingApproval) => {
+ setAlwaysError(null);
+ setAlwaysDraft({ id: request.id, kinds: String(request.details?.event_kind ?? '') });
+ };
+
+ const onSaveAlwaysAllow = async (request: PendingApproval) => {
+ if (!alwaysDraft) return;
+ const parsed = parseKindsInput(alwaysDraft.kinds);
+ if (!parsed.ok) {
+ setAlwaysError(parsed.error);
+ return;
+ }
+ await handleNip46Approve(request.id, true, true, parsed.kinds);
+ setAlwaysDraft(null);
+ };
+
const modeBadge = () => {
if (mode === 'nip46_client') {
return isNip46Active ? (
@@ -574,34 +601,67 @@ export function SignerModeScreen() {
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
Pending ({nip46StatusState!.pending_approvals!.length})
- {(nip46StatusState!.pending_approvals ?? []).map((r) => (
-
-
-
{r.method}
-
{r.summary}
+ {(nip46StatusState?.pending_approvals ?? []).map((r) => {
+ const editingAlways = alwaysDraft?.id === r.id;
+ return (
+
+
+
{r.method}
+
{r.summary}
+ {editingAlways && (
+
+
+ setAlwaysDraft({ id: r.id, kinds: e.target.value })
+ }
+ />
+
+
+
+ )}
+ {editingAlways && alwaysError &&
{alwaysError}}
+
+
+
+ {r.method === 'sign_event' && !editingAlways ? (
+
+ ) : (
+
+ )}
+
+
-
-
-
-
-
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx
index 1248028..f4390ec 100644
--- a/frontend/src/screens/SignerScreen.tsx
+++ b/frontend/src/screens/SignerScreen.tsx
@@ -5,8 +5,8 @@ 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 { grantLabel, parseKindsInput } from '../lib/permissions';
+import type { PendingApproval, SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = {
@@ -99,10 +99,15 @@ export function SignerScreen() {
}
};
- const onApprove = async (id: string, approved: boolean, always = false) => {
+ const onApprove = async (
+ id: string,
+ approved: boolean,
+ always = false,
+ grantKinds?: number[],
+ ) => {
setError(null);
try {
- setStatus(await signerApprove(id, approved, always));
+ setStatus(await signerApprove(id, approved, always, grantKinds));
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -127,6 +132,28 @@ export function SignerScreen() {
const grantKey = (g: SignerGrant) => `${g.app_pubkey}:${g.method}`;
+ // Interactive kind scope at APPROVAL time: "Always allow…" on a sign_event
+ // request opens a kind editor prefilled with the request's own kind, so the
+ // grant's scope is chosen while the user sees the event, not only after.
+ const [alwaysDraft, setAlwaysDraft] = useState<{ id: string; kinds: string } | null>(null);
+ const [alwaysError, setAlwaysError] = useState(null);
+
+ const startAlwaysAllow = (request: PendingApproval) => {
+ setAlwaysError(null);
+ setAlwaysDraft({ id: request.id, kinds: String(request.details?.event_kind ?? '') });
+ };
+
+ const onSaveAlwaysAllow = async (request: PendingApproval) => {
+ if (!alwaysDraft) return;
+ const parsed = parseKindsInput(alwaysDraft.kinds);
+ if (!parsed.ok) {
+ setAlwaysError(parsed.error);
+ return;
+ }
+ await onApprove(request.id, true, true, parsed.kinds);
+ setAlwaysDraft(null);
+ };
+
const startEditGrant = (grant: SignerGrant) => {
setKindError(null);
setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') });
@@ -134,18 +161,14 @@ export function SignerScreen() {
const onSaveKinds = async (grant: SignerGrant) => {
if (!grantDraft) return;
- const parts = grantDraft.kinds
- .split(',')
- .map((s) => s.trim())
- .filter((s) => s.length > 0);
- const bad = parts.find((s) => !/^\d+$/.test(s));
- if (bad !== undefined) {
- setKindError(`“${bad}” is not an event kind number.`);
+ const parsed = parseKindsInput(grantDraft.kinds);
+ if (!parsed.ok) {
+ setKindError(parsed.error);
return;
}
setError(null);
try {
- await signerGrantUpdate(grant.app_pubkey, grant.method, parts.map(Number));
+ await signerGrantUpdate(grant.app_pubkey, grant.method, parsed.kinds);
setGrants(await signerGrantsList());
setGrantDraft(null);
setKindError(null);
@@ -252,31 +275,62 @@ export function SignerScreen() {
The connected app wants to do the following with the active profile's keys.
Review each one before approving it.
- {status.pending.map((request) => (
-
-
-
{request.method}
-
{request.summary}
+ {status.pending.map((request) => {
+ const editingAlways = alwaysDraft?.id === request.id;
+ return (
+
+
+
{request.method}
+
{request.summary}
+ {editingAlways && (
+
+
+ setAlwaysDraft({ id: request.id, kinds: e.target.value })
+ }
+ />
+
+
+
+ )}
+ {editingAlways && alwaysError &&
{alwaysError}}
+
+
+
+ {request.method === 'sign_event' && !editingAlways ? (
+
+ ) : (
+
+ )}
+
+
-
-
-
-
-
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx
index 0a154f8..ed27939 100644
--- a/frontend/src/state/AppProvider.tsx
+++ b/frontend/src/state/AppProvider.tsx
@@ -86,12 +86,22 @@ interface AppContextValue {
nip46Disconnect: () => Promise;
nip46CancelPairing: () => Promise;
nip46Status: () => Promise;
- nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise;
+ nip46Approve: (
+ id: string,
+ approved: boolean,
+ always?: boolean,
+ grantKinds?: number[],
+ ) => Promise;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise;
signerDisconnect: () => Promise;
signerStatus: () => Promise;
- signerApprove: (id: string, approved: boolean, always?: boolean) => Promise;
+ signerApprove: (
+ id: string,
+ approved: boolean,
+ always?: boolean,
+ grantKinds?: number[],
+ ) => Promise;
signerGrantsList: () => Promise;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
signerGrantUpdate: (
@@ -301,7 +311,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
- (id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always),
+ (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
+ api.nip46Approve(id, approved, always, grantKinds),
[],
);
@@ -340,9 +351,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
const signerStatus = useCallback(() => api.signerStatus(), []);
- const signerApprove = useCallback((id: string, approved: boolean) => {
- return api.signerApprove(id, approved);
- }, []);
+ const signerApprove = useCallback(
+ (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
+ api.signerApprove(id, approved, always, grantKinds),
+ [],
+ );
const deleteProfile = useCallback(
(npub: string) => applyState(api.deleteProfile(npub)),
diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx
index bad7146..96ae105 100644
--- a/frontend/src/test/SignerScreen.test.tsx
+++ b/frontend/src/test/SignerScreen.test.tsx
@@ -245,4 +245,133 @@ describe('SignerScreen', () => {
});
expect(await screen.findByText('Sign events — all kinds')).toBeInTheDocument();
});
+
+ it('edits the kind scope when always-allowing a sign_event request', async () => {
+ const backend = createFakeBackend();
+ installFakeBackend(backend);
+ const user = userEvent.setup();
+ renderWithApp();
+
+ backend.setSigner({
+ phase: 'connected',
+ peer: 'ab12',
+ relays: ['wss://relay.damus.io'],
+ connectedRelays: ['wss://relay.damus.io'],
+ error: null,
+ pending: [
+ {
+ id: 'req-9',
+ method: 'sign_event',
+ summary: 'Sign event kind 1: “Scoped always”',
+ details: {
+ method: 'sign_event',
+ summary: 'Sign event kind 1',
+ event_kind: 1,
+ destination_relays: [],
+ content_preview: 'Scoped always',
+ is_sensitive: false,
+ },
+ },
+ ],
+ });
+
+ expect(await screen.findByText(/Scoped always/, {}, { timeout: 3000 })).toBeInTheDocument();
+ // sign_event offers the interactive scope editor, prefilled with the
+ // request's own kind.
+ await user.click(screen.getByRole('button', { name: /Always allow…/ }));
+ const input = screen.getByLabelText('Event kinds to always allow');
+ expect(input).toHaveValue('1');
+ await user.clear(input);
+ await user.type(input, '1, 30023');
+ await user.click(screen.getByRole('button', { name: 'Allow' }));
+
+ await waitFor(() => {
+ expect(
+ backend.requests.some(
+ (r) =>
+ r.method === 'signer_approve' &&
+ r.params?.id === 'req-9' &&
+ r.params?.approved === true &&
+ r.params?.always === true &&
+ JSON.stringify(r.params?.grant_kinds) === '[1,30023]',
+ ),
+ ).toBe(true);
+ });
+ expect(backend.signerGrants).toEqual([
+ { app_pubkey: 'ab12', method: 'sign_event', allowed_kinds: [1, 30023] },
+ ]);
+ });
+
+ it('rejects a non-numeric kind in the approval scope editor before calling the backend', async () => {
+ const backend = createFakeBackend();
+ installFakeBackend(backend);
+ const user = userEvent.setup();
+ renderWithApp();
+
+ backend.setSigner({
+ phase: 'connected',
+ peer: 'ab12',
+ relays: ['wss://relay.damus.io'],
+ connectedRelays: ['wss://relay.damus.io'],
+ error: null,
+ pending: [
+ {
+ id: 'req-10',
+ method: 'sign_event',
+ summary: 'Sign event kind 1: “Bad scope”',
+ details: {
+ method: 'sign_event',
+ summary: 'Sign event kind 1',
+ event_kind: 1,
+ destination_relays: [],
+ content_preview: 'Bad scope',
+ is_sensitive: false,
+ },
+ },
+ ],
+ });
+
+ await screen.findByText(/Bad scope/, {}, { timeout: 3000 });
+ await user.click(screen.getByRole('button', { name: /Always allow…/ }));
+ const input = screen.getByLabelText('Event kinds to always allow');
+ await user.clear(input);
+ await user.type(input, 'one');
+ await user.click(screen.getByRole('button', { name: 'Allow' }));
+
+ expect(await screen.findByText(/one.*not an event kind/)).toBeInTheDocument();
+ expect(backend.requests.some((r) => r.method === 'signer_approve')).toBe(false);
+ });
+
+ it('non-signing methods keep the plain Always allow button', async () => {
+ const backend = createFakeBackend();
+ installFakeBackend(backend);
+ const user = userEvent.setup();
+ renderWithApp();
+
+ backend.setSigner({
+ phase: 'connected',
+ peer: 'ab12',
+ relays: ['wss://relay.damus.io'],
+ connectedRelays: ['wss://relay.damus.io'],
+ error: null,
+ pending: [{ id: 'req-11', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
+ });
+
+ await screen.findByText('Decrypt a message', {}, { timeout: 3000 });
+ // No kind editor for methods without a kind dimension.
+ expect(screen.queryByRole('button', { name: /Always allow…/ })).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Always allow' }));
+
+ await waitFor(() => {
+ expect(
+ backend.requests.some(
+ (r) =>
+ r.method === 'signer_approve' && r.params?.id === 'req-11' && r.params?.always === true,
+ ),
+ ).toBe(true);
+ });
+ expect(backend.signerGrants).toEqual([
+ { app_pubkey: 'ab12', method: 'nip44_decrypt', allowed_kinds: [] },
+ ]);
+ });
});
diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts
index bf47169..ab80789 100644
--- a/frontend/src/test/fakeBackend.ts
+++ b/frontend/src/test/fakeBackend.ts
@@ -234,8 +234,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next);
return next;
}
- case 'nip46_approve':
- return backend.nip46;
+ case 'nip46_approve': {
+ const id = String(params.id ?? '');
+ const entry = backend.nip46.pending_approvals?.find((r) => r.id === id);
+ const next = {
+ ...backend.nip46,
+ pending_approvals: (backend.nip46.pending_approvals ?? []).filter((r) => r.id !== id),
+ };
+ backend.setNip46(next);
+ // Mirrors respond_to_approval_with_always: an always-allow on a
+ // sign_event grant is kind-scoped — grant_kinds as edited, else the
+ // kind of the request being approved.
+ if (params.approved === true && params.always === true && entry) {
+ const kinds =
+ (params.grant_kinds as number[] | null | undefined) ??
+ (entry.method === 'sign_event' && entry.details?.event_kind != null
+ ? [entry.details.event_kind]
+ : []);
+ const normalised = [...new Set(kinds)].sort((a, b) => a - b);
+ const peer = backend.nip46.signer_pubkey ?? '';
+ if (
+ !backend.signerGrants.some((g) => g.app_pubkey === peer && g.method === entry.method)
+ ) {
+ backend.signerGrants = [
+ ...backend.signerGrants,
+ { app_pubkey: peer, method: entry.method, allowed_kinds: normalised },
+ ];
+ }
+ }
+ return next;
+ }
case 'create_profile': {
const label = String(params.label ?? '');
@@ -411,10 +439,26 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
};
backend.setSigner(next);
if (params.approved === true && params.always === true && entry) {
- if (!backend.signerGrants.some((g) => g.method === entry.method)) {
+ // Mirrors respond_to_approval_with_always: grant_kinds as edited,
+ // else the kind of the request being approved (sign_event only).
+ const kinds =
+ (params.grant_kinds as number[] | null | undefined) ??
+ (entry.method === 'sign_event' && entry.details?.event_kind != null
+ ? [entry.details.event_kind]
+ : []);
+ const normalised = [...new Set(kinds)].sort((a, b) => a - b);
+ if (
+ !backend.signerGrants.some(
+ (g) => g.app_pubkey === (backend.signer.peer ?? '') && g.method === entry.method,
+ )
+ ) {
backend.signerGrants = [
...backend.signerGrants,
- { app_pubkey: backend.signer.peer ?? '', method: entry.method },
+ {
+ app_pubkey: backend.signer.peer ?? '',
+ method: entry.method,
+ allowed_kinds: normalised,
+ },
];
}
}
diff --git a/frontend/src/test/permissions.test.ts b/frontend/src/test/permissions.test.ts
index d5d6f08..a930d51 100644
--- a/frontend/src/test/permissions.test.ts
+++ b/frontend/src/test/permissions.test.ts
@@ -3,6 +3,7 @@ import {
declaredPermissionRows,
formatExpiry,
grantLabel,
+ parseKindsInput,
permissionLabel,
} from '../lib/permissions';
@@ -51,3 +52,20 @@ describe('formatExpiry', () => {
expect(formatExpiry(undefined)).toBeNull();
});
});
+
+describe('parseKindsInput', () => {
+ it('parses, de-duplicates and sorts a kind list', () => {
+ expect(parseKindsInput('30023, 1,1')).toEqual({ ok: true, kinds: [1, 30023] });
+ });
+
+ it('empty input is the explicit all-kinds list', () => {
+ expect(parseKindsInput(' , ')).toEqual({ ok: true, kinds: [] });
+ expect(parseKindsInput('')).toEqual({ ok: true, kinds: [] });
+ });
+
+ it('names the first non-numeric token', () => {
+ const r = parseKindsInput('1, hello, 7');
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toMatch(/hello/);
+ });
+});
diff --git a/src/ipc.rs b/src/ipc.rs
index dc0789c..ebce82f 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -187,12 +187,16 @@ pub enum Request {
Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally
/// records a standing grant so this peer's future requests of the same
- /// method run without prompting.
+ /// method run without prompting. `grant_kinds` (with `always`) scopes a
+ /// `sign_event` grant to the given event kinds as edited in the approval
+ /// UI; `None` falls back to the kind of the request being approved.
Nip46Approve {
id: String,
approved: bool,
#[serde(default)]
always: bool,
+ #[serde(default)]
+ grant_kinds: Option>,
},
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
@@ -214,6 +218,10 @@ pub enum Request {
/// grant for this peer + method.
#[serde(default)]
always: bool,
+ /// Kind scope for the grant (with `always`), as edited in the
+ /// approval UI; `None` falls back to the approved request's kind.
+ #[serde(default)]
+ grant_kinds: Option>,
},
/// List standing "always allow" grants for apps using us as signer.
SignerGrantsList,
@@ -561,6 +569,7 @@ fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> se
"id": p.id,
"method": p.method,
"summary": p.summary,
+ "details": p.details,
})).collect::>(),
})
}
@@ -694,12 +703,13 @@ async fn run(app: &Arc>, request: Request) -> Result {
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized"));
};
signer
- .respond_to_approval_with_always(&id, approved, always)
+ .respond_to_approval_with_always(&id, approved, always, grant_kinds)
.await?;
let status = signer.status().await;
Ok(json!(status))
@@ -743,12 +753,13 @@ async fn run(app: &Arc>, request: Request) -> Result {
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer
- .respond_to_approval_with_always(&id, approved, always)
+ .respond_to_approval_with_always(&id, approved, always, grant_kinds)
.await?;
let status = signer.status().await;
return Ok(nip46_status_as_bunker_json(&status));
diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs
index 9b220f1..eeb88ca 100644
--- a/src/signer/nip46_client.rs
+++ b/src/signer/nip46_client.rs
@@ -1404,7 +1404,7 @@ impl Nip46ClientSigner {
/// Approve or reject a pending request.
pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> {
- self.respond_to_approval_with_always(id, approved, false)
+ self.respond_to_approval_with_always(id, approved, false, None)
.await
}
@@ -1416,6 +1416,7 @@ impl Nip46ClientSigner {
id: &str,
approved: bool,
always: bool,
+ grant_kinds: Option>,
) -> Result<(), AppError> {
let (entry, peer_hex) = {
let mut inner = self.inner.lock().await;
@@ -1433,13 +1434,28 @@ impl Nip46ClientSigner {
(entry, peer)
};
if approved && always && !peer_hex.is_empty() {
- // A "sign_event" always-allow covers only the kinds of the
- // request the user actually saw — never other kinds. Other
- // gated methods have no kind dimension.
- let kinds: Vec = if entry.method == "sign_event" {
- entry.details.event_kind.into_iter().collect()
- } else {
- Vec::new()
+ // The scope of an always-allow grant:
+ // - `grant_kinds = Some(list)` — the user edited the scope in the
+ // approval UI; normalize (sorted + de-duped) and store verbatim.
+ // An empty Some list broadens to all kinds, the same explicit
+ // act the grant editor requires, never the result of omission.
+ // - `grant_kinds = None` — fall back to the request the user
+ // actually saw: a "sign_event" always-allow covers only that
+ // event's kind; other gated methods have no kind dimension.
+ let kinds: Vec = match grant_kinds {
+ Some(list) => {
+ let mut normalised = list;
+ normalised.sort_unstable();
+ normalised.dedup();
+ normalised
+ }
+ None => {
+ if entry.method == "sign_event" {
+ entry.details.event_kind.into_iter().collect()
+ } else {
+ Vec::new()
+ }
+ }
};
let mut app = self.app.lock().await;
app.vault