Extends the admin-delete hang fix across the whole app. Audited every signing route: - Native (CapacitorAmberSigner): already fixed — computes id locally. - Web/PWA (AmberSigner redirect flow): the callback handlers did a raw JSON.parse(result), but Amber returns a BARE signature (returnType= 'signature'), so this threw or produced an event with an empty id that stalls the relay — the same "stuck" defect as native. Added a shared reconstructSignedEvent() helper that rebuilds the event from the stored template + signature and computes the id via getEventHash. Applied in both AmberCallbackPage and AmberCallbackHandler. - NIP-17 DMs, zap requests, vouches, crew invites, membership, etc. all sign via user.signer.signEvent and therefore inherit the fix. - nsec / bunker / extension signers use Nostrify base classes (compute ids internally) — unaffected. - Native nip04/nip44 encrypt/decrypt are timeout-protected — no hang. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
728 lines
22 KiB
TypeScript
728 lines
22 KiB
TypeScript
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
|
import { getEventHash } 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);
|
|
|
|
return { ...base, id, sig };
|
|
}
|
|
|
|
/**
|
|
* 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(() => {});
|
|
},
|
|
};
|
|
}
|