SkateSpots/src/pages/AmberCallbackPage.tsx
SkateSpots a8b4b9fdd0 fix(security): tier 1 — money & auth (C1–C5 partial)
- 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.
2026-10-02 21:19:10 +07:00

201 lines
7.7 KiB
TypeScript

/**
* AmberCallbackPage
*
* Amber (NIP-55) redirects back to /amber-callback?event=<pubkey|signedEvent>
* after the user approves a request. This page processes the result and
* redirects the user back to where they came from (or home).
*
* On Android browsers (including GrapheneOS), window.location.href navigates
* the tab away, so we use localStorage (not sessionStorage) for persistence.
*/
import { useEffect, useRef, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import {
getAmberCallbackResult,
getAmberPendingOperationAsync,
clearAmberPendingOperation,
cleanAmberCallbackUrl,
getAmberLogin,
reconstructSignedEvent,
} from '@/lib/amber';
import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
type Status = 'processing' | 'success' | 'error';
export default function AmberCallbackPage() {
const navigate = useNavigate();
const login = useLoginActions();
const { toast } = useToast();
const hasProcessed = useRef(false);
const [status, setStatus] = useState<Status>('processing');
const [message, setMessage] = useState('Completing login with Amber...');
useSeoMeta({
title: 'Signing in with Amber - SkateSpots',
description: 'Completing your Amber (NIP-55) login.',
});
useEffect(() => {
if (hasProcessed.current) return;
hasProcessed.current = true;
processCallback();
async function processCallback() {
const result = getAmberCallbackResult();
cleanAmberCallbackUrl();
if (!result) {
setStatus('error');
setMessage('No result received from Amber. The request may have been cancelled.');
clearAmberPendingOperation();
return;
}
const pendingOp = await getAmberPendingOperationAsync();
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
// SECURITY: only accept a result that matches an operation this app
// actually started. Never treat a bare `?event=<pubkey>` URL as a login —
// that would let any link switch the user into an arbitrary account.
if (!pendingOp) {
setStatus('error');
setMessage('Login session expired or not found. Return to SkateSpots and try again.');
clearAmberPendingOperation();
return;
}
// ── get_public_key (login) ──────────────────────────────────────────
const isLoginOp = pendingOp.type === 'get_public_key';
if (isLoginOp) {
if (pubkeyRegex.test(result)) {
// Store pubkey under a SEPARATE key so the original tab's polling
// detects it and calls login.amber() itself — this works even when
// Amber opens the callback in a brand new tab (GrapheneOS behaviour)
localStorage.setItem('amber_pending_pubkey', result.toLowerCase());
clearAmberPendingOperation();
setStatus('success');
setMessage('Approved! You can close this tab and return to SkateSpots.');
// Also try to complete login in THIS tab in case it is the same tab
try {
login.amber(result.toLowerCase());
} catch { /* ignore — may fail if called twice */ }
redirectBack(true);
} else {
setStatus('error');
setMessage('Invalid response from Amber. Please try again.');
clearAmberPendingOperation();
}
return;
}
// ── sign_event ──────────────────────────────────────────────────────
if (pendingOp.type === 'sign_event') {
// Amber returns a BARE signature (returnType='signature'), so we
// reconstruct the full event from the original template + signature and
// compute its id locally. See reconstructSignedEvent() for the why.
const signedEvent = reconstructSignedEvent(
result,
pendingOp.eventJson,
getAmberLogin() ?? '',
);
if (signedEvent) {
localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation();
setStatus('success');
setMessage('Event signed! Returning...');
toast({ title: 'Event signed', description: 'Your event was signed by Amber.' });
redirectBack(true);
} else {
setStatus('error');
setMessage('Failed to process signed event from Amber.');
clearAmberPendingOperation();
}
return;
}
// ── encrypt / decrypt ───────────────────────────────────────────────
if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
localStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
setStatus('success');
setMessage('Operation complete! Returning...');
redirectBack(true);
return;
}
clearAmberPendingOperation();
redirectBack(false);
}
function redirectBack(success: boolean) {
// Use localStorage — survives navigation on Android browsers
const returnTo = localStorage.getItem('amber_return_path') || '/';
localStorage.removeItem('amber_return_path');
localStorage.removeItem('amber_login_attempt_ts');
setTimeout(() => navigate(returnTo, { replace: true }), success ? 800 : 0);
}
}, [login, toast, navigate]);
return (
<div className="min-h-screen bg-black flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-6">
{/* Icon */}
<div className="flex justify-center">
{status === 'processing' && (
<div className="w-20 h-20 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center">
<Loader2 className="w-10 h-10 text-[#ccff00] animate-spin" />
</div>
)}
{status === 'success' && (
<div className="w-20 h-20 rounded-full bg-green-500/10 border-2 border-green-500/40 flex items-center justify-center">
<CheckCircle2 className="w-10 h-10 text-green-500" />
</div>
)}
{status === 'error' && (
<div className="w-20 h-20 rounded-full bg-red-500/10 border-2 border-red-500/40 flex items-center justify-center">
<XCircle className="w-10 h-10 text-red-500" />
</div>
)}
</div>
{/* Message */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-widest mb-1">Amber · NIP-55</p>
<p className="text-white font-bold text-lg">{message}</p>
</div>
{/* Processing dots */}
{status === 'processing' && (
<div className="flex justify-center gap-1.5">
{[0, 1, 2].map(i => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
)}
{/* Error fallback buttons */}
{status === 'error' && (
<div className="space-y-3">
<Link to="/">
<Button className="w-full gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90 font-bold uppercase rounded-full">
<Home className="w-4 h-4" />
Go Home
</Button>
</Link>
</div>
)}
</div>
</div>
);
}