feat(desktop): streamline nostr identity pairing (#1974)

Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-16 12:44:14 -07:00
committed by GitHub
co-authored by npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54
parent 74f3f29758
commit 1eae8575ed
4 changed files with 325 additions and 105 deletions
@@ -1,5 +1,6 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { toast } from "sonner";
@@ -88,6 +89,11 @@ function normalizeVerificationCode(value: string): string[] {
.map((character) => character.trim());
}
function isNostrBindRequestExpired(payload: NostrBindDeepLinkPayload): boolean {
const expiry = new Date(payload.expiresAt).getTime();
return Number.isNaN(expiry) || expiry <= Date.now();
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -135,6 +141,74 @@ async function returnSignedResponseToBrowser(
}
}
function SignedResponseControls({
copyFailed,
copyLabel,
onCopy,
signedResponse,
}: {
copyFailed: boolean;
copyLabel: string;
onCopy: () => void;
signedResponse: string;
}) {
return (
<div className="space-y-4" data-testid="nostr-bind-manual-fallback-content">
<pre
className="max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
data-testid="nostr-bind-signed-response"
>
<code className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{signedResponse}
</code>
</pre>
{copyFailed ? (
<p className="w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
{COPY_FAILURE_MESSAGE}
</p>
) : null}
<Button
aria-label={copyLabel}
className="h-10 w-full"
data-testid="nostr-bind-copy-response"
onClick={onCopy}
type="button"
>
<span aria-live="polite" className="sr-only">
{copyLabel}
</span>
<span
aria-hidden="true"
className="inline-grid h-5 place-items-center overflow-hidden"
>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyLabel === "Copy response"
? "translate-y-0 opacity-100"
: "-translate-y-0.5 opacity-0",
)}
>
Copy response
</span>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
copyLabel === "Copied"
? "translate-y-0 opacity-100"
: "translate-y-0.5 opacity-0",
)}
>
Copied
</span>
</span>
</Button>
</div>
);
}
export function NostrBindConsentDialog() {
const isPreview = isNostrBindPreviewEnabled();
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
@@ -154,12 +228,15 @@ export function NostrBindConsentDialog() {
const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false);
const [copyFailed, setCopyFailed] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isManualFallbackOpen, setIsManualFallbackOpen] = React.useState(false);
const codeInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const codeShakeRef = React.useRef<HTMLDivElement | null>(null);
const codeShakeAnimationRef = React.useRef<Animation | null>(null);
const copiedTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const autoSignAttemptRef = React.useRef<string | null>(null);
const activeSignAttemptRef = React.useRef<symbol | null>(null);
const systemColorScheme = useSystemColorScheme();
const shouldReduceMotion = useReducedMotion();
const enteredVerificationCode = verificationCode.join("");
@@ -204,13 +281,17 @@ export function NostrBindConsentDialog() {
const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => {
clearCopiedState();
autoSignAttemptRef.current = null;
activeSignAttemptRef.current = null;
setPayload(nextPayload);
setIdentity(null);
setIsSigning(false);
setSignedResponse(null);
setVerificationCode(createEmptyVerificationCode());
setHasCodeMismatch(false);
setCopyFailed(false);
setError(null);
setIsManualFallbackOpen(false);
getIdentity()
.then(setIdentity)
.catch((error) => {
@@ -225,22 +306,19 @@ export function NostrBindConsentDialog() {
};
}, [clearCopiedState, isPreview]);
const isExpired = React.useMemo(() => {
if (!payload) {
return false;
}
const expiry = new Date(payload.expiresAt).getTime();
return Number.isNaN(expiry) || expiry <= Date.now();
}, [payload]);
const isExpired = payload !== null && isNostrBindRequestExpired(payload);
const resetDialog = React.useCallback(() => {
clearCopiedState();
autoSignAttemptRef.current = null;
activeSignAttemptRef.current = null;
setPayload(null);
setSignedResponse(null);
setVerificationCode(createEmptyVerificationCode());
setHasCodeMismatch(false);
setCopyFailed(false);
setError(null);
setIsManualFallbackOpen(false);
setIdentity(null);
setIsSigning(false);
}, [clearCopiedState]);
@@ -297,6 +375,7 @@ export function NostrBindConsentDialog() {
(index: number, value: string) => {
const nextDigits = value.replace(/\D/g, "");
const next = [...verificationCode];
autoSignAttemptRef.current = null;
if (!nextDigits) {
next[index] = "";
@@ -363,6 +442,7 @@ export function NostrBindConsentDialog() {
}
event.preventDefault();
autoSignAttemptRef.current = null;
const next = normalizeVerificationCode(pastedCode);
setVerificationCode(next);
if (
@@ -402,6 +482,7 @@ export function NostrBindConsentDialog() {
if (event.key === "Backspace") {
event.preventDefault();
autoSignAttemptRef.current = null;
const targetIndex = verificationCode[index]
? index
: Math.max(index - 1, 0);
@@ -429,10 +510,10 @@ export function NostrBindConsentDialog() {
);
const handleSign = React.useCallback(async () => {
if (!payload) {
if (activeSignAttemptRef.current || !payload) {
return;
}
if (isExpired) {
if (isNostrBindRequestExpired(payload)) {
setError(EXPIRED_LINK_MESSAGE);
return;
}
@@ -447,6 +528,8 @@ export function NostrBindConsentDialog() {
return;
}
const attempt = Symbol("nostr-bind-sign");
activeSignAttemptRef.current = attempt;
setIsSigning(true);
clearCopiedState();
setError(null);
@@ -461,21 +544,35 @@ export function NostrBindConsentDialog() {
origin: payload.origin,
expiresAt: payload.expiresAt,
});
if (activeSignAttemptRef.current !== attempt) {
return;
}
setSignedResponse(signed);
if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) {
setError(
await returnSignedResponseToBrowser(payload.callbackUrl, signed),
const callbackError = await returnSignedResponseToBrowser(
payload.callbackUrl,
signed,
);
if (activeSignAttemptRef.current !== attempt) {
return;
}
setError(callbackError);
setIsManualFallbackOpen(callbackError !== null);
}
} catch (error) {
setError(formatError(error) || "Failed to sign binding response.");
if (activeSignAttemptRef.current === attempt) {
setError(formatError(error) || "Failed to sign binding response.");
}
} finally {
setIsSigning(false);
if (activeSignAttemptRef.current === attempt) {
activeSignAttemptRef.current = null;
setIsSigning(false);
}
}
}, [
clearCopiedState,
enteredVerificationCode,
isExpired,
isPreview,
isVerificationCodeComplete,
isVerificationCodeValid,
@@ -484,6 +581,35 @@ export function NostrBindConsentDialog() {
verificationCode,
]);
React.useEffect(() => {
if (
!payload ||
identity === null ||
isExpired ||
isSigning ||
signedResponse !== null ||
!isVerificationCodeValid
) {
return;
}
const attemptKey = `${payload.challengeId}:${enteredVerificationCode}`;
if (autoSignAttemptRef.current === attemptKey) {
return;
}
autoSignAttemptRef.current = attemptKey;
void handleSign();
}, [
enteredVerificationCode,
handleSign,
identity,
isExpired,
isSigning,
isVerificationCodeValid,
payload,
signedResponse,
]);
const handleCopyAgain = React.useCallback(async () => {
if (!signedResponse) {
return;
@@ -548,7 +674,7 @@ export function NostrBindConsentDialog() {
id="nostr-bind-description"
>
{payload.returnMode === "browser_fragment_v1"
? "Buzz opened your browser to finish verification. If it did not open, copy the response below."
? "Buzz opened your browser to finish verification."
: "Copy the response below, then paste it into the Buzz website to finish verification."}
</DialogPrimitive.Description>
@@ -558,65 +684,50 @@ export function NostrBindConsentDialog() {
</p>
) : null}
<pre
className="mt-10 max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
data-testid="nostr-bind-signed-response"
>
<code className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
{signedResponse}
</code>
</pre>
{copyFailed ? (
<p className="mt-4 w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
{COPY_FAILURE_MESSAGE}
</p>
) : null}
{payload.returnMode === "browser_fragment_v1" ? (
<details
className="group mt-10 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/30 text-left shadow-xs"
data-testid="nostr-bind-manual-fallback"
onToggle={(event) =>
setIsManualFallbackOpen(event.currentTarget.open)
}
open={isManualFallbackOpen}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<span>Pairing didnt finish automatically?</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-border/55 p-4">
<p className="text-sm leading-6 text-muted-foreground">
Copy this response and paste it into the pairing page.
</p>
<SignedResponseControls
copyFailed={copyFailed}
copyLabel={finishCopyButtonLabel}
onCopy={handleCopyAgain}
signedResponse={signedResponse}
/>
</div>
</details>
) : (
<div className="mt-10 w-full">
<SignedResponseControls
copyFailed={copyFailed}
copyLabel={finishCopyButtonLabel}
onCopy={handleCopyAgain}
signedResponse={signedResponse}
/>
</div>
)}
<div className="mt-8 flex w-full flex-col gap-3">
<Button
aria-label={finishCopyButtonLabel}
className="h-10 w-full"
data-testid="nostr-bind-copy-response"
onClick={handleCopyAgain}
type="button"
>
<span aria-live="polite" className="sr-only">
{finishCopyButtonLabel}
</span>
<span
aria-hidden="true"
className="inline-grid h-5 place-items-center overflow-hidden"
>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
finishCopyButtonLabel === "Copy response"
? "translate-y-0 opacity-100"
: "-translate-y-0.5 opacity-0",
)}
>
Copy response
</span>
<span
className={cn(
COPY_BUTTON_LABEL_CLASS,
finishCopyButtonLabel === "Copied"
? "translate-y-0 opacity-100"
: "translate-y-0.5 opacity-0",
)}
>
Copied
</span>
</span>
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
onClick={() => handleOpenChange(false)}
type="button"
variant="ghost"
>
Close
Continue
</Button>
</div>
</OnboardingSlideTransition>
+11
View File
@@ -189,6 +189,10 @@ type E2eConfig = {
* Linux .deb install where Tauri's updater cannot swap the binary).
* Defaults to true for all existing tests. */
autoUpdateSupported?: boolean;
/** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */
openerError?: string;
/** Delay binding signatures so specs can exercise request supersession. */
nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -8641,6 +8645,10 @@ export function maybeInstallE2eTauriMocks() {
origin: string;
verificationCode: string;
};
const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
if (signDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, signDelayMs));
}
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
return JSON.stringify({
id: "e2e-signed-nostr-binding",
@@ -9654,6 +9662,9 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof sendToMockSocket>[0],
);
case "plugin:opener|open_url":
if (activeConfig?.mock?.openerError) {
throw new Error(activeConfig.mock.openerError);
}
openedExternalUrls.push(String((payload as { url: string | URL }).url));
return null;
case "get_e2e_opened_external_urls":
+133 -39
View File
@@ -28,21 +28,7 @@ const VALID_REQUEST: NostrBindPayload = {
version: "1",
};
async function openNostrBind(
page: Page,
payload: NostrBindPayload = VALID_REQUEST,
) {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
async function emitNostrBind(page: Page, payload: NostrBindPayload) {
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
@@ -62,7 +48,24 @@ async function openNostrBind(
payload: nextPayload,
});
}, payload);
}
async function openNostrBind(
page: Page,
payload: NostrBindPayload = VALID_REQUEST,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock);
await page.goto("/");
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
);
await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
@@ -150,7 +153,7 @@ async function shakeCount(page: Page): Promise<number> {
);
}
test("supports OTP entry, navigation, and paste without signing incomplete input", async ({
test("supports OTP entry and navigation without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
@@ -174,42 +177,70 @@ test("supports OTP entry, navigation, and paste without signing incomplete input
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
await pasteCode(first, VALID_REQUEST.verificationCode);
for (const [index, digit] of [...VALID_REQUEST.verificationCode].entries()) {
await expect(
page.getByTestId(`nostr-bind-code-digit-${index + 1}`),
).toHaveValue(digit);
}
await expect(page.getByTestId("nostr-bind-code-digit-6")).toBeFocused();
await expect(continueButton).toBeEnabled();
});
test("locks a filled sixth slot and repeats mismatch feedback without signing", async ({
test("auto-signs exactly once when the sixth correct digit is typed", async ({
page,
}) => {
await openNostrBind(page);
await page.getByTestId("nostr-bind-code-digit-1").click();
for (const digit of VALID_REQUEST.verificationCode) {
await page.keyboard.type(digit);
}
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.waitForTimeout(100);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
});
test("discards a signature when a newer pairing request arrives", async ({
page,
}) => {
await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
const nextRequest = {
...VALID_REQUEST,
challengeId: "550e8400-e29b-41d4-a716-446655440001",
verificationCode: "654321",
};
await emitNostrBind(page, nextRequest);
await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
await page.waitForTimeout(200);
await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
nextRequest.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
});
test("repeats mismatch feedback without signing", async ({ page }) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
const last = page.getByTestId("nostr-bind-code-digit-6");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await pasteCode(first, VALID_REQUEST.verificationCode);
await last.press("9");
await expect(last).toHaveValue("6");
await expect(continueButton).toBeEnabled();
await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
await expect.poll(() => shakeCount(page)).toBe(3);
await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
@@ -263,7 +294,6 @@ test("signs a valid request, shows the response, and copies it", async ({
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
@@ -296,11 +326,16 @@ test("signs a valid request, shows the response, and copies it", async ({
),
)
.toBe(signedResponse);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
@@ -326,11 +361,38 @@ test("returns a signed response in the callback fragment after consent", async (
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
await expect(manualFallback).not.toHaveAttribute("open", "");
await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
const fallbackSummary = manualFallback.locator("summary");
await fallbackSummary.focus();
await fallbackSummary.press("Enter");
await expect(manualFallback).toHaveAttribute("open", "");
const signedResponse = page.getByTestId("nostr-bind-signed-response");
await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
const copyResponse = page.getByTestId("nostr-bind-copy-response");
await expect(copyResponse).toBeVisible();
await copyResponse.click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(await signedResponse.textContent());
const callback = await page.evaluate(() => {
const command = (
(
@@ -352,6 +414,36 @@ test("returns a signed response in the callback fragment after consent", async (
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
test("opens the manual fallback when returning to the browser fails", async ({
page,
}) => {
await openNostrBind(
page,
{
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz",
returnMode: "browser_fragment_v1",
},
{ openerError: "browser unavailable" },
);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(
page.getByText(
"Could not open the browser. Copy the response below to finish manually.",
),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
"open",
"",
);
await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
});
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
@@ -361,11 +453,13 @@ test("keeps the signed response available when clipboard access fails", async ({
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page.getByText("Buzz couldn't access the clipboard. Try again."),
page
.getByTestId("nostr-bind-manual-fallback-content")
.getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
+4
View File
@@ -206,6 +206,10 @@ type MockBridgeOptions = {
/** Set to false to simulate a Linux .deb install where auto-update is not
* supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */
autoUpdateSupported?: boolean;
/** Reject browser opener calls to exercise manual pairing fallback UI. */
openerError?: string;
/** Delay binding signatures so specs can exercise request supersession. */
nostrBindSignDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — drive the archive-button gate matrix in