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.
This commit is contained in:
parent
8ffeb4296a
commit
d09c4ec1f0
10 changed files with 464 additions and 92 deletions
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,11 +601,37 @@ 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) => {
|
||||||
|
const editingAlways = alwaysDraft?.id === r.id;
|
||||||
|
return (
|
||||||
<div key={r.id} className="signer-pending-item">
|
<div key={r.id} className="signer-pending-item">
|
||||||
<div className="signer-pending-info">
|
<div className="signer-pending-info">
|
||||||
<code className="mono">{r.method}</code>
|
<code className="mono">{r.method}</code>
|
||||||
<p>{r.summary}</p>
|
<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>
|
||||||
<div className="settings-inline">
|
<div className="settings-inline">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -587,12 +640,18 @@ export function SignerModeScreen() {
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
{r.method === 'sign_event' && !editingAlways ? (
|
||||||
|
<Button variant="secondary" onClick={() => startAlwaysAllow(r)}>
|
||||||
|
Always allow…
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => void handleNip46Approve(r.id, true, true)}
|
onClick={() => void handleNip46Approve(r.id, true, true)}
|
||||||
>
|
>
|
||||||
Always allow
|
Always allow
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void handleNip46Approve(r.id, false)}
|
onClick={() => void handleNip46Approve(r.id, false)}
|
||||||
|
|
@ -601,7 +660,8 @@ export function SignerModeScreen() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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,17 +275,46 @@ export function SignerScreen() {
|
||||||
The connected app wants to do the following with the active profile's keys.
|
The connected app wants to do the following with the active profile's keys.
|
||||||
Review each one before approving it.
|
Review each one before approving it.
|
||||||
</p>
|
</p>
|
||||||
{status.pending.map((request) => (
|
{status.pending.map((request) => {
|
||||||
|
const editingAlways = alwaysDraft?.id === request.id;
|
||||||
|
return (
|
||||||
<div key={request.id} className="signer-pending-item">
|
<div key={request.id} className="signer-pending-item">
|
||||||
<div className="signer-pending-info">
|
<div className="signer-pending-info">
|
||||||
<code className="mono signer-pending-method">{request.method}</code>
|
<code className="mono signer-pending-method">{request.method}</code>
|
||||||
<p>{request.summary}</p>
|
<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>
|
||||||
<div className="settings-inline">
|
<div className="settings-inline">
|
||||||
<Button variant="primary" onClick={() => void onApprove(request.id, true)}>
|
<Button variant="primary" onClick={() => void onApprove(request.id, true)}>
|
||||||
<Icon name="check" size={16} />
|
<Icon name="check" size={16} />
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
{request.method === 'sign_event' && !editingAlways ? (
|
||||||
|
<Button variant="secondary" onClick={() => startAlwaysAllow(request)}>
|
||||||
|
<Icon name="check" size={16} />
|
||||||
|
Always allow…
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => void onApprove(request.id, true, true)}
|
onClick={() => void onApprove(request.id, true, true)}
|
||||||
|
|
@ -270,13 +322,15 @@ export function SignerScreen() {
|
||||||
<Icon name="check" size={16} />
|
<Icon name="check" size={16} />
|
||||||
Always allow
|
Always allow
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
|
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
|
||||||
<Icon name="trash" size={16} />
|
<Icon name="trash" size={16} />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
17
src/ipc.rs
17
src/ipc.rs
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
// 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<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()
|
entry.details.event_kind.into_iter().collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut app = self.app.lock().await;
|
let mut app = self.app.lock().await;
|
||||||
app.vault
|
app.vault
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue