- C1 Marketplace payments now verify the invoice amount: usePayPaymentPointer calls assertInvoiceAmount(invoice, amountSats) before paying, so a seller can't attach a raw BOLT11 or return an inflated LNURL invoice and have an auto-approving wallet pay it. - C2 NIP-17 DMs now verifyEvent(seal) in all four read paths — the seal is the only authentication for a message; a crafted seal could impersonate the sender. - C3 push-service: bind endpoint ownership (reject subscribe by a different pubkey; only the owner may unsubscribe) and reject non-HTTPS/private/loopback endpoints (SSRF). - C4 Amber callback: a bare ?event=<pubkey> is no longer treated as a login; the callback must match a pending get_public_key op (both callback pages). Also stops logging the pending op payload. - C5a platform-fee record is now per-pubkey, so an account on a shared device can't inherit another's paid status. - C5b (self-asserted featured ranking) deferred: needs a batch-verification pass against provider zap receipts before it can gate ordering.
210 lines
7.5 KiB
TypeScript
210 lines
7.5 KiB
TypeScript
/**
|
|
* AmberCallbackHandler
|
|
*
|
|
* This component handles Amber (NIP-55) callbacks at the app level.
|
|
* It processes results from Amber after signing operations and login.
|
|
*
|
|
* For web apps, Amber communication works via URL redirects:
|
|
* 1. App opens nostrsigner: URL to request signing
|
|
* 2. Amber opens and user approves/rejects
|
|
* 3. Amber redirects back to callbackUrl with result in query params
|
|
* 4. This component processes the result
|
|
*
|
|
* PWA NOTE: PWAs can lose localStorage when redirecting to external apps.
|
|
* This handler uses multiple fallback strategies:
|
|
* 1. Check localStorage, sessionStorage, and IndexedDB for pending operation
|
|
* 2. If all storage is lost but result looks like a pubkey, assume it's a login
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import {
|
|
isAmberCallback,
|
|
getAmberCallbackResult,
|
|
getAmberPendingOperationAsync,
|
|
clearAmberPendingOperation,
|
|
cleanAmberCallbackUrl,
|
|
getAmberLogin,
|
|
reconstructSignedEvent,
|
|
} from '@/lib/amber';
|
|
|
|
export function AmberCallbackHandler() {
|
|
const login = useLoginActions();
|
|
const { toast } = useToast();
|
|
const { t } = useTranslation();
|
|
const hasProcessed = useRef(false);
|
|
const [debugInfo, setDebugInfo] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
// If we're already on the /amber-callback route, AmberCallbackPage handles
|
|
// the processing — this global handler must stay silent to avoid a race.
|
|
const hashPath = window.location.hash.replace(/^#/, '').split('?')[0];
|
|
if (hashPath === '/amber-callback') {
|
|
return;
|
|
}
|
|
|
|
// Check if this is an Amber callback
|
|
const isCallback = isAmberCallback();
|
|
|
|
if (!isCallback) {
|
|
return;
|
|
}
|
|
|
|
// Only process once per page load
|
|
if (hasProcessed.current) {
|
|
return;
|
|
}
|
|
hasProcessed.current = true;
|
|
|
|
// Show debug info temporarily
|
|
setDebugInfo(t('settingsUi.amberCallback.processing'));
|
|
|
|
// Process async to allow IndexedDB lookup
|
|
processAmberCallback();
|
|
|
|
async function processAmberCallback() {
|
|
const result = getAmberCallbackResult();
|
|
|
|
setDebugInfo(t('settingsUi.amberCallback.gotResult', { result: result ? result.substring(0, 16) + '...' : 'null' }));
|
|
|
|
// Clean up URL immediately to prevent re-processing on refresh
|
|
cleanAmberCallbackUrl();
|
|
|
|
if (!result) {
|
|
// User cancelled or Amber failed
|
|
clearAmberPendingOperation();
|
|
setDebugInfo(t('settingsUi.amberCallback.noResultDebug'));
|
|
toast({
|
|
title: 'Amber',
|
|
description: t('settingsUi.amberCallback.noResultToast'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Try to get pending operation from any storage
|
|
const pendingOp = await getAmberPendingOperationAsync();
|
|
// Log only the operation TYPE — never the payload (encrypt/decrypt ops can
|
|
// carry plaintext).
|
|
console.log('[AmberCallback] Pending operation type:', pendingOp?.type ?? 'none');
|
|
|
|
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
|
|
// SECURITY: a callback is only valid when it matches an operation this app
|
|
// actually started. Never infer a login from a bare `?event=<pubkey>` URL —
|
|
// that would let any link switch the user into an arbitrary account.
|
|
if (!pendingOp) {
|
|
console.warn('[AmberCallback] No pending operation found — ignoring callback');
|
|
setDebugInfo(t('settingsUi.amberCallback.sessionLostToast'));
|
|
toast({
|
|
title: 'Amber',
|
|
description: t('settingsUi.amberCallback.sessionLostToast'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Process based on operation type
|
|
switch (pendingOp.type) {
|
|
case 'get_public_key': {
|
|
console.log('[AmberCallback] Processing get_public_key...');
|
|
|
|
if (pubkeyRegex.test(result)) {
|
|
try {
|
|
login.amber(result.toLowerCase());
|
|
clearAmberPendingOperation();
|
|
console.log('[AmberCallback] Login successful!');
|
|
toast({
|
|
title: t('settingsUi.amberCallback.loggedInTitle'),
|
|
description: t('settingsUi.amberCallback.amberLoginDesc'),
|
|
});
|
|
} catch (error) {
|
|
console.error('[AmberCallback] Amber login failed:', error);
|
|
clearAmberPendingOperation();
|
|
toast({
|
|
title: t('settingsUi.amberCallback.loginFailedTitle'),
|
|
description: t('settingsUi.amberCallback.loginFailedDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
} else {
|
|
console.warn('[AmberCallback] Invalid pubkey format:', result);
|
|
clearAmberPendingOperation();
|
|
toast({
|
|
title: t('settingsUi.amberCallback.loginFailedTitle'),
|
|
description: t('settingsUi.amberCallback.invalidPubkeyDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'sign_event': {
|
|
// Store the signed event result for the caller to retrieve.
|
|
// Amber returns a BARE signature (returnType='signature'), so we
|
|
// reconstruct the full event from the original template + signature
|
|
// and compute its id locally — a raw JSON.parse(result) would throw on
|
|
// a bare signature or produce an event with an empty id that stalls
|
|
// the relay. See reconstructSignedEvent() for details.
|
|
const signedEvent = reconstructSignedEvent(
|
|
result,
|
|
pendingOp.eventJson,
|
|
getAmberLogin() ?? '',
|
|
);
|
|
if (signedEvent) {
|
|
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
|
|
clearAmberPendingOperation();
|
|
toast({
|
|
title: t('settingsUi.amberCallback.eventSignedTitle'),
|
|
description: t('settingsUi.amberCallback.eventSignedDesc'),
|
|
});
|
|
// Reload the page to re-submit the event
|
|
// This is a workaround since we can't resolve the original promise
|
|
window.location.reload();
|
|
} else {
|
|
console.error('Failed to reconstruct signed event from Amber result');
|
|
clearAmberPendingOperation();
|
|
toast({
|
|
title: t('settingsUi.amberCallback.signingFailedTitle'),
|
|
description: t('settingsUi.amberCallback.signingFailedDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'nip04_encrypt':
|
|
case 'nip44_encrypt':
|
|
case 'nip04_decrypt':
|
|
case 'nip44_decrypt': {
|
|
// Store encryption/decryption results
|
|
sessionStorage.setItem('amber_crypto_result', result);
|
|
clearAmberPendingOperation();
|
|
toast({
|
|
title: t('settingsUi.amberCallback.operationCompleteTitle'),
|
|
description: t('settingsUi.amberCallback.operationCompleteDesc', { op: pendingOp.type.replace('_', ' ') }),
|
|
});
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
console.warn('Unknown Amber operation type:', pendingOp.type);
|
|
clearAmberPendingOperation();
|
|
}
|
|
}
|
|
}
|
|
}, [login, toast, t]);
|
|
|
|
// Show debug overlay when processing Amber callback
|
|
if (debugInfo) {
|
|
return (
|
|
<div className="fixed top-0 left-0 right-0 z-[9999] bg-yellow-500 text-black px-4 py-2 text-center text-sm font-mono">
|
|
🔐 Amber: {debugInfo}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|