SkateSpots/src/lib/amber.ts
SkateSpots 6def78fffe chore(lint): clear all no-unused-vars warnings (position-precise)
Removed/renamed the remaining 39 unused locals/dead code, file-by-file:
- unused destructured bindings/params (remove the binding, keep the call)
- unused single-line pure consts and dead functions/interfaces removed
- deleted the dead _FeedItem component and its now-unused imports
- amber _readChunks self-call fixed; SpotMap popup call site updated

Warnings: 182 -> 7 (only react-refresh/only-export-components remain).
no-explicit-any=0, exhaustive-deps=0, no-unused-vars=0.
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline + guidance updated.
2026-09-26 22:20:21 +07:00

744 lines
23 KiB
TypeScript

import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import { getEventHash, verifyEvent } from 'nostr-tools';
/**
* Pending Amber operation stored in multiple places for PWA resilience.
* Used to track operations that are waiting for Amber to return.
*/
export interface AmberPendingOperation {
type: 'get_public_key' | 'sign_event' | 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt';
/** Unique ID for the operation */
id: string;
/** The unsigned event JSON (for sign_event) */
eventJson?: string;
/** Timestamp when operation was initiated */
timestamp: number;
/** The pubkey to encrypt/decrypt with (for nip04/nip44 operations) */
targetPubkey?: string;
/** The plaintext to encrypt (for encrypt operations) */
plaintext?: string;
/** The ciphertext to decrypt (for decrypt operations) */
ciphertext?: string;
}
const AMBER_PENDING_KEY = 'amber_pending_operation';
const AMBER_LOGIN_KEY = 'amber_login';
/**
* Store pending operation info for when Amber returns.
* Uses multiple storage mechanisms for PWA resilience.
*/
export function setAmberPendingOperation(operation: AmberPendingOperation): void {
const json = JSON.stringify(operation);
// Store in multiple places for resilience (PWAs can lose localStorage on redirect)
try {
localStorage.setItem(AMBER_PENDING_KEY, json);
} catch (e) {
console.warn('[Amber] Failed to store in localStorage:', e);
}
try {
sessionStorage.setItem(AMBER_PENDING_KEY, json);
} catch (e) {
console.warn('[Amber] Failed to store in sessionStorage:', e);
}
// Also store in IndexedDB for maximum persistence
try {
const request = indexedDB.open('amber_store', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('pending')) {
db.createObjectStore('pending');
}
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('pending', 'readwrite');
tx.objectStore('pending').put(json, AMBER_PENDING_KEY);
};
} catch (e) {
console.warn('[Amber] Failed to store in IndexedDB:', e);
}
}
/**
* Get pending operation from any available storage.
*/
export function getAmberPendingOperation(): AmberPendingOperation | null {
// Try localStorage first
let stored = localStorage.getItem(AMBER_PENDING_KEY);
// Fall back to sessionStorage
if (!stored) {
stored = sessionStorage.getItem(AMBER_PENDING_KEY);
if (stored) {
console.log('[Amber] Found pending op in sessionStorage (localStorage was empty)');
}
}
if (!stored) return null;
try {
return JSON.parse(stored) as AmberPendingOperation;
} catch {
return null;
}
}
/**
* Get pending operation from IndexedDB (async).
*/
export async function getAmberPendingOperationAsync(): Promise<AmberPendingOperation | null> {
// First try sync storage
const syncResult = getAmberPendingOperation();
if (syncResult) return syncResult;
// Try IndexedDB
return new Promise((resolve) => {
try {
const request = indexedDB.open('amber_store', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('pending')) {
db.createObjectStore('pending');
}
};
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains('pending')) {
resolve(null);
return;
}
const tx = db.transaction('pending', 'readonly');
const getReq = tx.objectStore('pending').get(AMBER_PENDING_KEY);
getReq.onsuccess = () => {
if (getReq.result) {
console.log('[Amber] Found pending op in IndexedDB');
try {
resolve(JSON.parse(getReq.result));
} catch {
resolve(null);
}
} else {
resolve(null);
}
};
getReq.onerror = () => resolve(null);
};
request.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
/**
* Clear the pending operation from all storage.
*/
export function clearAmberPendingOperation(): void {
try {
localStorage.removeItem(AMBER_PENDING_KEY);
} catch { /* ignore */ }
try {
sessionStorage.removeItem(AMBER_PENDING_KEY);
} catch { /* ignore */ }
try {
const request = indexedDB.open('amber_store', 1);
request.onsuccess = () => {
const db = request.result;
if (db.objectStoreNames.contains('pending')) {
const tx = db.transaction('pending', 'readwrite');
tx.objectStore('pending').delete(AMBER_PENDING_KEY);
}
};
} catch { /* ignore */ }
}
/**
* Store Amber login (pubkey).
*/
export function setAmberLogin(pubkey: string): void {
localStorage.setItem(AMBER_LOGIN_KEY, pubkey);
}
/**
* Get stored Amber login pubkey.
*/
export function getAmberLogin(): string | null {
return localStorage.getItem(AMBER_LOGIN_KEY);
}
/**
* Clear Amber login.
*/
export function clearAmberLogin(): void {
localStorage.removeItem(AMBER_LOGIN_KEY);
}
/**
* Reconstruct a fully-formed, valid signed event from Amber's callback result.
*
* WHY THIS EXISTS
* ---------------
* We ask Amber with `returnType: 'signature'`, so on most Amber builds the
* callback `result` is a BARE 128-char hex signature — NOT a full event JSON.
* The old callback handlers did `JSON.parse(result)`, which either threw on the
* bare signature ("Failed to process signed event") or, when a build returned
* partial JSON, produced an event with an empty `id` that stalls the relay
* forever. This is the same defect that hung admin deletes on native.
*
* The event id is a deterministic NIP-01 hash of the signed fields, so we
* reconstruct the event from the ORIGINAL unsigned template (persisted as
* `eventJson` on the pending op) plus the returned signature, and compute the
* id ourselves. Handles every Amber response shape:
* - bare signature string
* - full signed-event JSON
* - partial JSON missing `id`
*
* @param result the raw callback payload from Amber (signature or JSON)
* @param eventJson the original unsigned event JSON we sent to Amber (if known)
* @param pubkey the signer's pubkey (Amber's key)
* @returns a valid NostrEvent, or null if it cannot be reconstructed
*/
export function reconstructSignedEvent(
result: string,
eventJson: string | undefined,
pubkey: string,
): NostrEvent | null {
const trimmed = (result ?? '').trim();
if (!trimmed) return null;
// Case 1: Amber returned full (or partial) event JSON.
let parsed: Partial<NostrEvent> | null = null;
if (trimmed.startsWith('{')) {
try {
parsed = JSON.parse(trimmed) as Partial<NostrEvent>;
} catch {
parsed = null;
}
}
// Case 2: Amber returned a bare hex signature (returnType='signature').
const looksLikeSig = /^[0-9a-fA-F]{128}$/.test(trimmed);
// Seed the base fields from the original template we sent to Amber.
let template: Partial<NostrEvent> = {};
if (eventJson) {
try {
template = JSON.parse(eventJson) as Partial<NostrEvent>;
} catch {
template = {};
}
}
const kind = parsed?.kind ?? template.kind;
const content = parsed?.content ?? template.content;
const tags = parsed?.tags ?? template.tags;
const created_at = parsed?.created_at ?? template.created_at;
const eventPubkey = parsed?.pubkey ?? template.pubkey ?? pubkey;
const sig = parsed?.sig ?? (looksLikeSig ? trimmed : undefined);
// Without these we cannot produce a valid event.
if (
kind === undefined ||
content === undefined ||
tags === undefined ||
created_at === undefined ||
!sig
) {
return null;
}
const base = { kind, content, tags, created_at, pubkey: eventPubkey };
const id =
parsed?.id && parsed.id.length === 64 ? parsed.id : getEventHash(base);
const signed: NostrEvent = { ...base, id, sig };
// DEFENSE-IN-DEPTH: the external signer (Amber) is a separate app and is not
// implicitly trusted. Before handing this event back to the app to be
// published or trusted, prove it is genuinely signed by `eventPubkey`.
// `verifyEvent` re-checks both the id (hash of the serialized event) and the
// Schnorr signature, so a compromised or buggy signer that returns a forged
// or mismatched signature cannot produce a publishable event — we return null
// (all callers treat null as "incomplete signature" and surface a friendly
// "approve the request in Amber, then try again" error).
try {
if (!verifyEvent(signed)) return null;
} catch {
return null;
}
return signed;
}
/**
* Extract search params from both the real query string AND the hash fragment.
*
* With HashRouter the URL looks like:
* https://localhost/#/amber-callback?event=XXXX
* In that case `window.location.search` is empty but the params are embedded
* inside `window.location.hash` after the path segment.
*/
function getSearchParams(): URLSearchParams {
// Normal query string (PWA / browser)
if (window.location.search) {
return new URLSearchParams(window.location.search);
}
// HashRouter: extract the query string portion from the hash
const hash = window.location.hash; // e.g. "#/amber-callback?event=XXX"
const qIndex = hash.indexOf('?');
if (qIndex !== -1) {
return new URLSearchParams(hash.slice(qIndex));
}
return new URLSearchParams();
}
/**
* Check if the current page was opened as an Amber callback.
* Also checks pre-captured data from main.tsx for PWA resilience.
*/
export function isAmberCallback(): boolean {
const params = getSearchParams();
if (params.has('event')) return true;
// Check pre-captured data (PWAs might lose URL params during hydration)
const preCaptured = sessionStorage.getItem('amber_callback_event');
if (preCaptured) {
console.log('[Amber] Found pre-captured callback data');
return true;
}
return false;
}
/**
* Get the result from Amber callback URL.
* Falls back to pre-captured data from main.tsx.
*/
export function getAmberCallbackResult(): string | null {
const params = getSearchParams();
const fromUrl = params.get('event');
if (fromUrl) {
console.log('[Amber] Got result from URL params:', fromUrl);
return fromUrl;
}
// Check pre-captured data
const preCaptured = sessionStorage.getItem('amber_callback_event');
if (preCaptured) {
console.log('[Amber] Got result from pre-captured data:', preCaptured);
// Clear it so we don't process it again
sessionStorage.removeItem('amber_callback_event');
sessionStorage.removeItem('amber_callback_url');
sessionStorage.removeItem('amber_callback_timestamp');
return preCaptured;
}
return null;
}
/**
* Clean the URL after processing Amber callback.
* With HashRouter we replace the hash to remove the query params.
*/
export function cleanAmberCallbackUrl(): void {
if (window.location.hash.includes('?')) {
// HashRouter: strip query from hash fragment
const hashPath = window.location.hash.split('?')[0];
window.history.replaceState({}, '', window.location.pathname + hashPath);
} else {
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
}
}
/**
* Generate a unique operation ID.
*/
function generateOperationId(): string {
return `amber_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Build the dedicated Amber callback URL.
*
* This app uses HashRouter, so ALL in-app routes live inside the hash fragment:
* https://skate-spots.org/#/amber-callback
*
* Amber will open the callbackUrl in the browser after the user approves.
* The URL must therefore use the hash-based path so React Router can match
* the /amber-callback route and render AmberCallbackPage.
*
* In the Capacitor APK we must use the app's custom URI scheme so Android fires
* an intent back into the app (not a browser navigation).
*
* The app ID is `org.skatespots.app` — Capacitor registers this as a URI scheme
* automatically via the `appId` in capacitor.config.ts.
*/
function buildCallbackUrl(): string {
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
if (isCapacitor) {
return 'org.skatespots.app://amber-callback?event=';
}
// IMPORTANT: The callbackUrl is embedded RAW inside an intent: string that uses
// # as a delimiter (intent:<payload>#Intent;...). So the callbackUrl CANNOT
// contain a # character — it would break the intent format.
//
// We use a plain URL without the hash fragment:
// https://skate-spots.org/amber-callback?event=<pubkey>
//
// The server (or Netlify/nsite) serves index.html for all paths, so the app
// loads at /amber-callback. main.tsx pre-captures ?event= before React hydrates,
// and AmberCallbackPage processes it via sessionStorage.
return `${window.location.origin}/amber-callback?event=`;
}
/**
* Navigate to an Amber intent URL.
*/
function openAmberUrl(url: string): void {
window.location.href = url;
}
/**
* Build an Android intent URL for Amber — the ONLY format that works from a browser.
*
* Per Amber README (https://github.com/greenart7c3/Amber) web apps section,
* the exact format is:
*
* intent:<payload>#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://example.com/?event=;end
*
* CRITICAL RULES:
* 1. Use intent: scheme, NOT nostrsigner: directly
* 2. Params go as S.<key>=<value> separated by semicolons
* 3. callbackUrl is NOT encoded — it is raw, including the trailing ?event=
* 4. payload (event JSON etc.) goes BEFORE the # and must be encodeURIComponent'd
* 5. window.href (not window.location.href, not window.open) per the spec
*/
function buildAmberUrl(payload: string, params: Record<string, string>): string {
const extras = Object.entries(params)
.map(([k, v]) => `S.${k}=${v}`)
.join(';');
return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`;
}
/**
* Open Amber for get_public_key WITHOUT a callback URL.
* Used in PWA/Capacitor mode: Amber copies pubkey to clipboard,
* the app reads it on visibilitychange.
*/
export function openAmberForLoginNoCallback(): void {
const amberUrl = buildAmberUrl('', {
compressionType: 'none',
returnType: 'signature',
type: 'get_public_key',
});
openAmberUrl(amberUrl);
}
/**
* Open Amber for get_public_key operation.
* Uses a dedicated /amber-callback route so the app state is preserved
* and the callback is processed reliably on return.
*/
export function openAmberForLogin(): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
const pendingOp = {
type: 'get_public_key' as const,
id: operationId,
timestamp: Date.now(),
};
setAmberPendingOperation(pendingOp);
// Store the current hash path so AmberCallbackPage can navigate back here
// after login (e.g. user was on /map and tapped Login → returns to /map).
const currentPath = window.location.hash.replace(/^#/, '') || '/';
localStorage.setItem('amber_return_path', currentPath);
// Android intent URL for Amber — callbackUrl is RAW (not encoded) per Amber README
const amberUrl = buildAmberUrl('', {
compressionType: 'none',
returnType: 'signature',
type: 'get_public_key',
callbackUrl: callbackUrl,
});
console.log('[Amber] Opening Amber for login, intent:', amberUrl);
openAmberUrl(amberUrl);
}
/**
* Subscribe to app becoming visible again after user switches back from Amber.
* Calls `onReturn` once when the page regains visibility.
* Returns an unsubscribe function.
*
* Usage:
* const unsub = subscribeToAmberReturn(() => checkClipboardForPubkey());
*/
export function subscribeToAmberReturn(onReturn: () => void): () => void {
const handler = () => {
if (document.visibilityState === 'visible') {
document.removeEventListener('visibilitychange', handler);
onReturn();
}
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}
/**
* Open Amber for sign_event operation.
* Returns a promise that resolves when user returns to the page.
* The actual result needs to be processed on page load.
*/
export function openAmberForSignEvent(event: Omit<NostrEvent, 'id' | 'sig'>): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
const eventJson = JSON.stringify(event);
setAmberPendingOperation({
type: 'sign_event',
id: operationId,
eventJson,
timestamp: Date.now(),
});
const amberUrl = buildAmberUrl(encodeURIComponent(eventJson), {
type: 'sign_event',
compressionType: 'none',
returnType: 'event',
callbackUrl: callbackUrl,
});
openAmberUrl(amberUrl);
}
/**
* Open Amber for NIP-04 encrypt operation.
*/
export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
setAmberPendingOperation({
type: 'nip04_encrypt',
id: operationId,
targetPubkey: pubkey,
plaintext,
timestamp: Date.now(),
});
const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), {
type: 'nip04_encrypt',
pubKey: pubkey,
compressionType: 'none',
returnType: 'signature',
callbackUrl: callbackUrl,
});
openAmberUrl(amberUrl);
}
/**
* Open Amber for NIP-04 decrypt operation.
*/
export function openAmberForNip04Decrypt(pubkey: string, ciphertext: string): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
setAmberPendingOperation({
type: 'nip04_decrypt',
id: operationId,
targetPubkey: pubkey,
ciphertext,
timestamp: Date.now(),
});
const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), {
type: 'nip04_decrypt',
pubKey: pubkey,
compressionType: 'none',
returnType: 'signature',
callbackUrl: callbackUrl,
});
openAmberUrl(amberUrl);
}
/**
* Open Amber for NIP-44 encrypt operation.
*/
export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
setAmberPendingOperation({
type: 'nip44_encrypt',
id: operationId,
targetPubkey: pubkey,
plaintext,
timestamp: Date.now(),
});
const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), {
type: 'nip44_encrypt',
pubKey: pubkey,
compressionType: 'none',
returnType: 'signature',
callbackUrl: callbackUrl,
});
openAmberUrl(amberUrl);
}
/**
* Open Amber for NIP-44 decrypt operation.
*/
export function openAmberForNip44Decrypt(pubkey: string, ciphertext: string): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
setAmberPendingOperation({
type: 'nip44_decrypt',
id: operationId,
targetPubkey: pubkey,
ciphertext,
timestamp: Date.now(),
});
const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), {
type: 'nip44_decrypt',
pubKey: pubkey,
compressionType: 'none',
returnType: 'signature',
callbackUrl: callbackUrl,
});
openAmberUrl(amberUrl);
}
/**
* Decode a gzip-compressed Amber result (prefixed with "Signer1").
* Falls back to treating as plain text if not compressed.
*/
export function decodeAmberResult(result: string): string {
if (result.startsWith('Signer1')) {
// Gzip compressed result
const base64 = result.substring(7); // Remove "Signer1" prefix
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Use DecompressionStream if available
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
writer.write(bytes);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
const _readChunks = async (): Promise<string> => {
const { done, value } = await reader.read();
if (done) {
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
chunks.push(value);
return _readChunks();
};
// For sync usage, we'll just return the raw result
// The caller should handle async decompression if needed
console.warn('Gzip decompression requires async handling');
return result;
} catch (e) {
console.error('Failed to decode gzip result:', e);
return result;
}
}
// Plain text result - might be URL encoded
try {
return decodeURIComponent(result);
} catch {
return result;
}
}
/**
* AmberSigner implements the NostrSigner interface for Amber.
*
* Note: Due to the nature of web-to-Amber communication via URL redirects,
* signing operations will redirect the user to Amber and require them to
* return to the app. This means sign() cannot return synchronously.
*
* For a better UX, consider using NIP-46 (Nostr Connect / Bunker) instead.
*/
export class AmberSigner implements NostrSigner {
readonly pubkey: string;
constructor(pubkey: string) {
this.pubkey = pubkey;
}
async getPublicKey(): Promise<string> {
return this.pubkey;
}
// NOTE: Nostrify's NostrSigner interface requires `signEvent` (not `sign`).
// The old method was named `sign`, so every `user.signer.signEvent(...)` call
// on a web/PWA Amber login threw "signer.signEvent is not a function". This
// now correctly implements the interface. On web, signing genuinely requires
// a redirect round-trip to Amber, so this navigates away and the result is
// processed on return by AmberCallbackPage.
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
// Store the current state and redirect to Amber
// The result will be processed on page reload
openAmberForSignEvent(event);
// This promise will never resolve in the current page context
// because we're redirecting to Amber
return new Promise(() => {
// The page will redirect, so this never resolves
});
}
readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
openAmberForNip04Encrypt(pubkey, plaintext);
return new Promise(() => {});
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
openAmberForNip04Decrypt(pubkey, ciphertext);
return new Promise(() => {});
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
openAmberForNip44Encrypt(pubkey, plaintext);
return new Promise(() => {});
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
openAmberForNip44Decrypt(pubkey, ciphertext);
return new Promise(() => {});
},
};
}