Compare commits

...

2 commits

Author SHA1 Message Date
Avi
38612a4a93 docs(checkpoint): Step 4 approval-time kind scope @ d09c4ec 2026-10-01 13:32:21 -05:00
Avi
d09c4ec1f0 feat(signer): interactive kind scope in the approval prompt (Step 4 finish)
'Always allow…' on a sign_event request now opens an inline kind editor
prefilled with the request's own kind, so the standing grant's scope is
chosen while the user sees the event. Approved with grant_kinds, the
backend records exactly the edited scope (normalised); without them the
fallback stays the request's own kind. Both approval UIs (Signer and
Signer Mode) share the parseKindsInput helper; the bunker status JSON now
carries pending 'details' so the legacy screen can prefill too.

Also fixes a latent bug: AppProvider.signerApprove dropped the 'always'
argument, so the legacy 'Always allow' button never actually recorded a
grant.
2026-10-01 13:31:36 -05:00
11 changed files with 525 additions and 92 deletions

View file

@ -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<Vec<u16>>` — 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) # Checkpoint — placeholder kind-0 fix + grant editing UI (2026-10-01)
## Where things are ## Where things are

View file

@ -127,15 +127,25 @@ export const api = {
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'), nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'), nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'), nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean, always = false) => nip46Approve: (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }), call<Nip46SignerStatus>('nip46_approve', {
id,
approved,
always,
grant_kinds: grantKinds ?? null,
}),
// Legacy NIP-46 bunker (deprecated, kept for compatibility) // Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }), signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'), signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'), signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean, always = false) => signerApprove: (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
call<SignerStatus>('signer_approve', { id, approved, always }), call<SignerStatus>('signer_approve', {
id,
approved,
always,
grant_kinds: grantKinds ?? null,
}),
// Standing "always allow" grants for apps using us as their signer. // Standing "always allow" grants for apps using us as their signer.
signerGrantsList: () => call<SignerGrant[]>('signer_grants_list'), signerGrantsList: () => call<SignerGrant[]>('signer_grants_list'),

View file

@ -50,3 +50,20 @@ export function formatExpiry(expiresAt?: number): string | null {
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString(); 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 };
}

View file

@ -5,8 +5,13 @@ import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { declaredPermissionRows, formatExpiry } from '../lib/permissions'; import { declaredPermissionRows, formatExpiry, parseKindsInput } from '../lib/permissions';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; import type {
SignerMode,
EmbeddedSignerStatus,
Nip46SignerStatus,
PendingApproval,
} from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
export function SignerModeScreen() { export function SignerModeScreen() {
@ -232,10 +237,10 @@ export function SignerModeScreen() {
); );
const handleNip46Approve = useCallback( const handleNip46Approve = useCallback(
async (id: string, approved: boolean, always = false) => { async (id: string, approved: boolean, always = false, grantKinds?: number[]) => {
setError(null); setError(null);
try { try {
const status = await nip46Approve(id, approved, always); const status = await nip46Approve(id, approved, always, grantKinds);
setNip46StatusState(status); setNip46StatusState(status);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@ -244,6 +249,28 @@ export function SignerModeScreen() {
[nip46Approve], [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<string | null>(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 = () => { const modeBadge = () => {
if (mode === 'nip46_client') { if (mode === 'nip46_client') {
return isNip46Active ? ( return isNip46Active ? (
@ -574,34 +601,67 @@ export function SignerModeScreen() {
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && ( {(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
<div className="signer-pending"> <div className="signer-pending">
<h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3> <h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3>
{(nip46StatusState!.pending_approvals ?? []).map((r) => ( {(nip46StatusState?.pending_approvals ?? []).map((r) => {
<div key={r.id} className="signer-pending-item"> const editingAlways = alwaysDraft?.id === r.id;
<div className="signer-pending-info"> return (
<code className="mono">{r.method}</code> <div key={r.id} className="signer-pending-item">
<p>{r.summary}</p> <div className="signer-pending-info">
<code className="mono">{r.method}</code>
<p>{r.summary}</p>
{editingAlways && (
<div className="settings-inline signer-grant-edit">
<input
className="signer-grant-kinds"
aria-label="Event kinds to always allow"
placeholder="e.g. 1, 30023 (empty = all kinds)"
value={alwaysDraft.kinds}
onChange={(e) =>
setAlwaysDraft({ id: r.id, kinds: e.target.value })
}
/>
<Button
variant="primary"
onClick={() => void onSaveAlwaysAllow(r)}
>
<Icon name="check" size={16} />
Allow
</Button>
<Button variant="secondary" onClick={() => setAlwaysDraft(null)}>
Cancel
</Button>
</div>
)}
{editingAlways && alwaysError && <ErrorText>{alwaysError}</ErrorText>}
</div>
<div className="settings-inline">
<Button
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
Approve
</Button>
{r.method === 'sign_event' && !editingAlways ? (
<Button variant="secondary" onClick={() => startAlwaysAllow(r)}>
Always allow…
</Button>
) : (
<Button
variant="secondary"
onClick={() => void handleNip46Approve(r.id, true, true)}
>
Always allow
</Button>
)}
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
Reject
</Button>
</div>
</div> </div>
<div className="settings-inline"> );
<Button })}
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => void handleNip46Approve(r.id, true, true)}
>
Always allow
</Button>
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
Reject
</Button>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>

View file

@ -5,8 +5,8 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format'; import { shortHexId } from '../lib/format';
import { grantLabel } from '../lib/permissions'; import { grantLabel, parseKindsInput } from '../lib/permissions';
import type { SignerGrant, SignerStatus } from '../lib/types'; import type { PendingApproval, SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = { 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); setError(null);
try { try {
setStatus(await signerApprove(id, approved, always)); setStatus(await signerApprove(id, approved, always, grantKinds));
setGrants(await signerGrantsList()); setGrants(await signerGrantsList());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(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}`; 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<string | null>(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) => { const startEditGrant = (grant: SignerGrant) => {
setKindError(null); setKindError(null);
setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') }); setGrantDraft({ key: grantKey(grant), kinds: (grant.allowed_kinds ?? []).join(', ') });
@ -134,18 +161,14 @@ export function SignerScreen() {
const onSaveKinds = async (grant: SignerGrant) => { const onSaveKinds = async (grant: SignerGrant) => {
if (!grantDraft) return; if (!grantDraft) return;
const parts = grantDraft.kinds const parsed = parseKindsInput(grantDraft.kinds);
.split(',') if (!parsed.ok) {
.map((s) => s.trim()) setKindError(parsed.error);
.filter((s) => s.length > 0);
const bad = parts.find((s) => !/^\d+$/.test(s));
if (bad !== undefined) {
setKindError(`“${bad}” is not an event kind number.`);
return; return;
} }
setError(null); setError(null);
try { try {
await signerGrantUpdate(grant.app_pubkey, grant.method, parts.map(Number)); await signerGrantUpdate(grant.app_pubkey, grant.method, parsed.kinds);
setGrants(await signerGrantsList()); setGrants(await signerGrantsList());
setGrantDraft(null); setGrantDraft(null);
setKindError(null); setKindError(null);
@ -252,31 +275,62 @@ export function SignerScreen() {
The connected app wants to do the following with the active profile&apos;s keys. The connected app wants to do the following with the active profile&apos;s keys.
Review each one before approving it. Review each one before approving it.
</p> </p>
{status.pending.map((request) => ( {status.pending.map((request) => {
<div key={request.id} className="signer-pending-item"> const editingAlways = alwaysDraft?.id === request.id;
<div className="signer-pending-info"> return (
<code className="mono signer-pending-method">{request.method}</code> <div key={request.id} className="signer-pending-item">
<p>{request.summary}</p> <div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{editingAlways && (
<div className="settings-inline signer-grant-edit">
<input
className="signer-grant-kinds"
aria-label="Event kinds to always allow"
placeholder="e.g. 1, 30023 (empty = all kinds)"
value={alwaysDraft.kinds}
onChange={(e) =>
setAlwaysDraft({ id: request.id, kinds: e.target.value })
}
/>
<Button variant="primary" onClick={() => void onSaveAlwaysAllow(request)}>
<Icon name="check" size={16} />
Allow
</Button>
<Button variant="secondary" onClick={() => setAlwaysDraft(null)}>
Cancel
</Button>
</div>
)}
{editingAlways && alwaysError && <ErrorText>{alwaysError}</ErrorText>}
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void onApprove(request.id, true)}>
<Icon name="check" size={16} />
Approve
</Button>
{request.method === 'sign_event' && !editingAlways ? (
<Button variant="secondary" onClick={() => startAlwaysAllow(request)}>
<Icon name="check" size={16} />
Always allow…
</Button>
) : (
<Button
variant="secondary"
onClick={() => void onApprove(request.id, true, true)}
>
<Icon name="check" size={16} />
Always allow
</Button>
)}
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} />
Reject
</Button>
</div>
</div> </div>
<div className="settings-inline"> );
<Button variant="primary" onClick={() => void onApprove(request.id, true)}> })}
<Icon name="check" size={16} />
Approve
</Button>
<Button
variant="secondary"
onClick={() => void onApprove(request.id, true, true)}
>
<Icon name="check" size={16} />
Always allow
</Button>
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} />
Reject
</Button>
</div>
</div>
))}
</div> </div>
</section> </section>
)} )}

View file

@ -86,12 +86,22 @@ interface AppContextValue {
nip46Disconnect: () => Promise<Nip46SignerStatus>; nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46CancelPairing: () => Promise<Nip46SignerStatus>; nip46CancelPairing: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>; nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>; nip46Approve: (
id: string,
approved: boolean,
always?: boolean,
grantKinds?: number[],
) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated) // Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>; signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>; signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>; signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>; signerApprove: (
id: string,
approved: boolean,
always?: boolean,
grantKinds?: number[],
) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>; signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
signerGrantUpdate: ( signerGrantUpdate: (
@ -301,7 +311,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
const nip46Status = useCallback(() => api.nip46Status(), []); const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []); const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback( 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 signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
const signerDisconnect = useCallback(() => api.signerDisconnect(), []); const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
const signerStatus = useCallback(() => api.signerStatus(), []); const signerStatus = useCallback(() => api.signerStatus(), []);
const signerApprove = useCallback((id: string, approved: boolean) => { const signerApprove = useCallback(
return api.signerApprove(id, approved); (id: string, approved: boolean, always = false, grantKinds?: number[]) =>
}, []); api.signerApprove(id, approved, always, grantKinds),
[],
);
const deleteProfile = useCallback( const deleteProfile = useCallback(
(npub: string) => applyState(api.deleteProfile(npub)), (npub: string) => applyState(api.deleteProfile(npub)),

View file

@ -245,4 +245,133 @@ describe('SignerScreen', () => {
}); });
expect(await screen.findByText('Sign events — all kinds')).toBeInTheDocument(); 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(<SignerScreen />);
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(<SignerScreen />);
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(<SignerScreen />);
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: [] },
]);
});
}); });

View file

@ -234,8 +234,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next); backend.setNip46(next);
return next; return next;
} }
case 'nip46_approve': case 'nip46_approve': {
return backend.nip46; 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': { case 'create_profile': {
const label = String(params.label ?? ''); const label = String(params.label ?? '');
@ -411,10 +439,26 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
}; };
backend.setSigner(next); backend.setSigner(next);
if (params.approved === true && params.always === true && entry) { 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 = [
...backend.signerGrants, ...backend.signerGrants,
{ app_pubkey: backend.signer.peer ?? '', method: entry.method }, {
app_pubkey: backend.signer.peer ?? '',
method: entry.method,
allowed_kinds: normalised,
},
]; ];
} }
} }

View file

@ -3,6 +3,7 @@ import {
declaredPermissionRows, declaredPermissionRows,
formatExpiry, formatExpiry,
grantLabel, grantLabel,
parseKindsInput,
permissionLabel, permissionLabel,
} from '../lib/permissions'; } from '../lib/permissions';
@ -51,3 +52,20 @@ describe('formatExpiry', () => {
expect(formatExpiry(undefined)).toBeNull(); 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/);
});
});

View file

@ -187,12 +187,16 @@ pub enum Request {
Nip46Status, Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally /// Approve/reject a pending NIP-46 request. `always = true` additionally
/// records a standing grant so this peer's future requests of the same /// 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 { Nip46Approve {
id: String, id: String,
approved: bool, approved: bool,
#[serde(default)] #[serde(default)]
always: bool, always: bool,
#[serde(default)]
grant_kinds: Option<Vec<u16>>,
}, },
/// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). /// 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. /// grant for this peer + method.
#[serde(default)] #[serde(default)]
always: bool, 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<Vec<u16>>,
}, },
/// List standing "always allow" grants for apps using us as signer. /// List standing "always allow" grants for apps using us as signer.
SignerGrantsList, SignerGrantsList,
@ -561,6 +569,7 @@ fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> se
"id": p.id, "id": p.id,
"method": p.method, "method": p.method,
"summary": p.summary, "summary": p.summary,
"details": p.details,
})).collect::<Vec<_>>(), })).collect::<Vec<_>>(),
}) })
} }
@ -694,12 +703,13 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
id, id,
approved, approved,
always, always,
grant_kinds,
} => { } => {
let Some(signer) = ensure_nip46_signer(app).await else { let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized")); return Err(AppError::config("NIP-46 signer not initialized"));
}; };
signer signer
.respond_to_approval_with_always(&id, approved, always) .respond_to_approval_with_always(&id, approved, always, grant_kinds)
.await?; .await?;
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
@ -743,12 +753,13 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
id, id,
approved, approved,
always, always,
grant_kinds,
} => { } => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer signer
.respond_to_approval_with_always(&id, approved, always) .respond_to_approval_with_always(&id, approved, always, grant_kinds)
.await?; .await?;
let status = signer.status().await; let status = signer.status().await;
return Ok(nip46_status_as_bunker_json(&status)); return Ok(nip46_status_as_bunker_json(&status));

View file

@ -1404,7 +1404,7 @@ impl Nip46ClientSigner {
/// Approve or reject a pending request. /// Approve or reject a pending request.
pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { 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 .await
} }
@ -1416,6 +1416,7 @@ impl Nip46ClientSigner {
id: &str, id: &str,
approved: bool, approved: bool,
always: bool, always: bool,
grant_kinds: Option<Vec<u16>>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let (entry, peer_hex) = { let (entry, peer_hex) = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
@ -1433,13 +1434,28 @@ impl Nip46ClientSigner {
(entry, peer) (entry, peer)
}; };
if approved && always && !peer_hex.is_empty() { if approved && always && !peer_hex.is_empty() {
// A "sign_event" always-allow covers only the kinds of the // The scope of an always-allow grant:
// request the user actually saw — never other kinds. Other // - `grant_kinds = Some(list)` — the user edited the scope in the
// gated methods have no kind dimension. // approval UI; normalize (sorted + de-duped) and store verbatim.
let kinds: Vec<u16> = if entry.method == "sign_event" { // An empty Some list broadens to all kinds, the same explicit
entry.details.event_kind.into_iter().collect() // act the grant editor requires, never the result of omission.
} else { // - `grant_kinds = None` — fall back to the request the user
Vec::new() // actually saw: a "sign_event" always-allow covers only that
// event's kind; other gated methods have no kind dimension.
let kinds: Vec<u16> = 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; let mut app = self.app.lock().await;
app.vault app.vault