Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions lib/error-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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<string, unknown> = {};
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.
Expand All @@ -49,21 +78,28 @@ export function captureError(
extra?: Record<string, unknown>
},
): 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 = []
Expand Down
55 changes: 55 additions & 0 deletions lib/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationPermissionState> {
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}`,
});
}
90 changes: 90 additions & 0 deletions lib/offline-transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
createdAt: number;
updatedAt: number;
}

function openIndexedDB(): Promise<IDBDatabase> {
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<void> {
try {
const db = await openIndexedDB();
await new Promise<void>((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<DraftStreamBatch[]> {
try {
const db = await openIndexedDB();
return await new Promise<DraftStreamBatch[]>((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<void> {
try {
const db = await openIndexedDB();
await new Promise<void>((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 [];
}
}

44 changes: 30 additions & 14 deletions lib/stream-sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,45 @@ function endValue(endTime: number): number {
return endTime === 0 ? Infinity : endTime;
}

export function sortStreams<T extends { info: Pick<StreamInfo, "ratePerSecond" | "endTime"> }>(
rows: T[],
// Memoized comparator cache for multi-key stream sorting (#751)
const comparatorCache = new Map<SortKey, <T extends { info: Pick<StreamInfo, "ratePerSecond" | "endTime"> }>(a: T, b: T) => number>();

function getComparator<T extends { info: Pick<StreamInfo, "ratePerSecond" | "endTime"> }>(
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<T extends { info: Pick<StreamInfo, "ratePerSecond" | "endTime"> }>(
rows: T[],
sort: SortKey,
): T[] {
if (sort === "default" || rows.length <= 1) return rows;
const comparator = getComparator<T>(sort);
return [...rows].sort(comparator);
}