diff --git a/lib/error-tracking.ts b/lib/error-tracking.ts index b78033b..8c3b16f 100644 --- a/lib/error-tracking.ts +++ b/lib/error-tracking.ts @@ -38,6 +38,35 @@ export function addBreadcrumb(crumb: ErrorBreadcrumb): void { } } +// ── PII & Key Scrubber (#752) ──────────────────────────────────────────────── + +const STELLAR_PUBLIC_KEY_REGEX = /\bG[A-Z2-7]{55}\b/g; +const STELLAR_SECRET_KEY_REGEX = /\bS[A-Z2-7]{55}\b/g; + +/** + * Scrub sensitive Stellar public and secret keys from strings before telemetry dispatch (#752). + */ +export function scrubSensitiveData(text: string): string { + if (!text) return text; + return text + .replace(STELLAR_SECRET_KEY_REGEX, '[REDACTED_SECRET_KEY]') + .replace(STELLAR_PUBLIC_KEY_REGEX, '[REDACTED_PUBLIC_KEY]'); +} + +export function scrubObject(obj: T): T { + if (!obj || typeof obj !== 'object') { + return typeof obj === 'string' ? (scrubSensitiveData(obj) as unknown as T) : obj; + } + if (Array.isArray(obj)) { + return obj.map(scrubObject) as unknown as T; + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = scrubObject(value); + } + return result as T; +} + /** * Capture an error with optional context and breadcrumbs. * When Sentry is not configured, this is a no-op that just logs to console. @@ -49,21 +78,28 @@ export function captureError( extra?: Record }, ): void { - const err = error instanceof Error ? error : new Error(String(error)) + const rawErr = error instanceof Error ? error : new Error(String(error)) + const cleanMessage = scrubSensitiveData(rawErr.message) + const err = rawErr.message === cleanMessage ? rawErr : new Error(cleanMessage) + if (rawErr.stack) { + err.stack = scrubSensitiveData(rawErr.stack) + } + + const cleanContext = context ? scrubObject(context) : context if (!SENTRY_DSN) { - console.error('[error-tracking]', err.message, { context, breadcrumbs: breadcrumbs.slice(-5) }) + console.error('[error-tracking]', err.message, { context: cleanContext, breadcrumbs: breadcrumbs.slice(-5) }) return } Sentry.withScope(scope => { - if (context?.tags) { - Object.entries(context.tags).forEach(([k, v]) => scope.setTag(k, v)) + if (cleanContext?.tags) { + Object.entries(cleanContext.tags).forEach(([k, v]) => scope.setTag(k, v)) } - if (context?.extra) { - Object.entries(context.extra).forEach(([k, v]) => scope.setExtra(k, v)) + if (cleanContext?.extra) { + Object.entries(cleanContext.extra).forEach(([k, v]) => scope.setExtra(k, v)) } - breadcrumbs.forEach(c => scope.addBreadcrumb(c)) + breadcrumbs.forEach(c => scope.addBreadcrumb({ ...c, message: scrubSensitiveData(c.message) })) Sentry.captureException(err) }) breadcrumbs = [] diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..2b502aa --- /dev/null +++ b/lib/notifications.ts @@ -0,0 +1,55 @@ +/** + * Browser Web Push / Notifications API helper for stream milestones and claims (#754). + */ + +export type NotificationPermissionState = NotificationPermission | 'unsupported'; + +/** + * Check if the browser supports the Notifications API. + */ +export function isNotificationSupported(): boolean { + return typeof window !== 'undefined' && 'Notification' in window; +} + +/** + * Request notification permissions from the user. + */ +export async function requestNotificationPermission(): Promise { + if (!isNotificationSupported()) return 'unsupported'; + try { + return await Notification.requestPermission(); + } catch { + return 'denied'; + } +} + +/** + * Dispatch a browser notification when a stream milestone or claim completes. + */ +export function notifyStreamMilestone(title: string, options?: NotificationOptions): Notification | null { + if (!isNotificationSupported()) return null; + if (Notification.permission !== 'granted') return null; + + try { + return new Notification(title, { + icon: '/favicon.ico', + badge: '/favicon.ico', + ...options, + }); + } catch (error) { + console.warn('[notifications] Failed to dispatch notification:', error); + return null; + } +} + +/** + * Convenience helper to notify user when a stream claim or milestone is confirmed. + */ +export function notifyClaimConfirmed(streamId: string, amount?: string): void { + notifyStreamMilestone('Stream Claim Confirmed', { + body: amount + ? `Your claim of ${amount} for stream ${streamId.slice(0, 8)}... has been confirmed.` + : `Your stream milestone claim for ${streamId.slice(0, 8)}... has been confirmed.`, + tag: `claim-${streamId}`, + }); +} diff --git a/lib/offline-transactions.ts b/lib/offline-transactions.ts index 2da5215..610fe2c 100644 --- a/lib/offline-transactions.ts +++ b/lib/offline-transactions.ts @@ -51,3 +51,93 @@ export async function flushQueuedTransactions(signTx: SignTransaction): Promise< } } } + +// ── IndexedDB Storage Adapter for Draft Streams (#753) ───────────────────────── + +const IDB_NAME = 'conduit-offline-db'; +const IDB_VERSION = 1; +const DRAFTS_STORE = 'draft-stream-batches'; + +export interface DraftStreamBatch { + id: string; + name: string; + streams: Array>; + createdAt: number; + updatedAt: number; +} + +function openIndexedDB(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + return reject(new Error('IndexedDB not supported')); + } + const request = indexedDB.open(IDB_NAME, IDB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(DRAFTS_STORE)) { + db.createObjectStore(DRAFTS_STORE, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function saveDraftStreamBatch(batch: DraftStreamBatch): Promise { + try { + const db = await openIndexedDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(DRAFTS_STORE, 'readwrite'); + tx.objectStore(DRAFTS_STORE).put(batch); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + if (typeof localStorage !== 'undefined') { + const drafts = getLocalDraftBatches(); + const updated = drafts.filter((d) => d.id !== batch.id).concat(batch); + localStorage.setItem(`${KEY}-drafts`, JSON.stringify(updated)); + } + } +} + +export async function getDraftStreamBatches(): Promise { + try { + const db = await openIndexedDB(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(DRAFTS_STORE, 'readonly'); + const req = tx.objectStore(DRAFTS_STORE).getAll(); + req.onsuccess = () => resolve(req.result as DraftStreamBatch[]); + req.onerror = () => reject(req.error); + }); + } catch { + return getLocalDraftBatches(); + } +} + +export async function deleteDraftStreamBatch(id: string): Promise { + try { + const db = await openIndexedDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(DRAFTS_STORE, 'readwrite'); + tx.objectStore(DRAFTS_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + if (typeof localStorage !== 'undefined') { + const drafts = getLocalDraftBatches().filter((d) => d.id !== id); + localStorage.setItem(`${KEY}-drafts`, JSON.stringify(drafts)); + } + } +} + +function getLocalDraftBatches(): DraftStreamBatch[] { + if (typeof localStorage === 'undefined') return []; + try { + return JSON.parse(localStorage.getItem(`${KEY}-drafts`) ?? '[]') as DraftStreamBatch[]; + } catch { + return []; + } +} + diff --git a/lib/stream-sort.ts b/lib/stream-sort.ts index c31bb39..e8a908a 100644 --- a/lib/stream-sort.ts +++ b/lib/stream-sort.ts @@ -21,29 +21,45 @@ function endValue(endTime: number): number { return endTime === 0 ? Infinity : endTime; } -export function sortStreams }>( - rows: T[], +// Memoized comparator cache for multi-key stream sorting (#751) +const comparatorCache = new Map }>(a: T, b: T) => number>(); + +function getComparator }>( sort: SortKey, -): T[] { - if (sort === "default") return rows; - const sorted = [...rows]; +): (a: T, b: T) => number { + const cached = comparatorCache.get(sort); + if (cached) return cached; + + let comparator: (a: T, b: T) => number; switch (sort) { case "amount-asc": - sorted.sort((a, b) => - a.info.ratePerSecond < b.info.ratePerSecond ? -1 : a.info.ratePerSecond > b.info.ratePerSecond ? 1 : 0, - ); + comparator = (a, b) => + a.info.ratePerSecond < b.info.ratePerSecond ? -1 : a.info.ratePerSecond > b.info.ratePerSecond ? 1 : 0; break; case "amount-desc": - sorted.sort((a, b) => - a.info.ratePerSecond > b.info.ratePerSecond ? -1 : a.info.ratePerSecond < b.info.ratePerSecond ? 1 : 0, - ); + comparator = (a, b) => + a.info.ratePerSecond > b.info.ratePerSecond ? -1 : a.info.ratePerSecond < b.info.ratePerSecond ? 1 : 0; break; case "end-asc": - sorted.sort((a, b) => endValue(a.info.endTime) - endValue(b.info.endTime)); + comparator = (a, b) => endValue(a.info.endTime) - endValue(b.info.endTime); break; case "end-desc": - sorted.sort((a, b) => endValue(b.info.endTime) - endValue(a.info.endTime)); + comparator = (a, b) => endValue(b.info.endTime) - endValue(a.info.endTime); + break; + default: + comparator = () => 0; break; } - return sorted; + + comparatorCache.set(sort, comparator as any); + return comparator; +} + +export function sortStreams }>( + rows: T[], + sort: SortKey, +): T[] { + if (sort === "default" || rows.length <= 1) return rows; + const comparator = getComparator(sort); + return [...rows].sort(comparator); }