Skip to content
Open
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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 5 additions & 5 deletions src/lib/filters.js → src/lib/filters.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
function toNumber(value, fallback = 0) {
const parsed = Number(value);
function toNumber(value: unknown, fallback = 0) {
const parsed = Number(value as any);
return Number.isFinite(parsed) ? parsed : fallback;
}

export function applyTransactionFilters(transactions = [], filters = {}) {
export function applyTransactionFilters(transactions: any[] = [], filters: any = {}) {
return transactions.filter((tx) => {
if (filters.status === "success" && !tx.successful) return false;
if (filters.status === "failed" && tx.successful) return false;
Expand All @@ -22,7 +22,7 @@ export function applyTransactionFilters(transactions = [], filters = {}) {
});
}

export function applyOperationFilters(operations = [], filters = {}) {
export function applyOperationFilters(operations: any[] = [], filters: any = {}) {
return operations.filter((op) => {
if (filters.type && filters.type !== "all" && op.type !== filters.type) {
return false;
Expand All @@ -34,7 +34,7 @@ export function applyOperationFilters(operations = [], filters = {}) {
});
}

export function applyAssetFilters(assets = [], filters = {}) {
export function applyAssetFilters(assets: any[] = [], filters: any = {}) {
return assets.filter((asset) => {
if (filters.verifiedOnly && !asset.is_verified) return false;
if (filters.minHolders && toNumber(asset.num_accounts) < toNumber(filters.minHolders)) {
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
78 changes: 71 additions & 7 deletions src/lib/rateLimiter.js → src/lib/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,79 @@
* Implements token bucket algorithm with intelligent request batching and queue management
*/

type RequestPriority = 'high' | 'medium' | 'low';

type ThrottleMode = 'aggressive' | 'conservative';

interface RequestPayload {
url: string;
options?: RequestInit;
priority?: RequestPriority;
maxRetries?: number;
method?: string;
}

interface QueuedRequest {
id: string;
request: RequestPayload;
identifier: string;
timestamp: number;
resolve?: (value: Response | PromiseLike<Response>) => void;
reject?: (reason?: any) => void;
priority: RequestPriority;
endpoint: string;
retryCount: number;
maxRetries: number;
}

interface RateLimitBucket {
tokens: number;
lastRefill: number;
endpointUsage: Map<string, number>;
}

interface RateLimiterStats {
totalRequests: number;
queuedRequests: number;
batchedRequests: number;
rejectedRequests: number;
droppedRequests: number;
averageResponseTime: number;
endpointUsage: Map<string, number>;
}

interface RateLimiterOptions {
windowMs?: number;
maxRequests?: number;
batchSize?: number;
batchTimeout?: number;
throttleMode?: ThrottleMode;
maxQueueSize?: number;
}

class RateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || 60000; // Default: 1 minute
this.maxRequests = options.maxRequests || 30; // Default: 30 requests per minute
private windowMs: number;
private maxRequests: number;
private buckets: Map<string, RateLimitBucket>;
private requestQueue: Map<string, QueuedRequest[]>;
private batchSize: number;
private batchTimeout: number;
private throttleMode: ThrottleMode;
private maxQueueSize: number;
private priorityQueues: Record<RequestPriority, QueuedRequest[]>;
private cleanupInterval: ReturnType<typeof setInterval>;
private processingInterval: ReturnType<typeof setInterval>;
private statistics: RateLimiterStats;

constructor(options: RateLimiterOptions = {}) {
this.windowMs = options.windowMs ?? 60000; // Default: 1 minute
this.maxRequests = options.maxRequests ?? 30; // Default: 30 requests per minute
this.buckets = new Map(); // Store tokens per user/IP
this.requestQueue = new Map(); // Request queues per endpoint type
this.batchSize = options.batchSize || 10; // Max batch size for request batching
this.batchTimeout = options.batchTimeout || 100; // Max wait time for batching (ms)
this.throttleMode = options.throttleMode || 'aggressive'; // 'aggressive' | 'conservative'
this.maxQueueSize = options.maxQueueSize || 100; // Maximum items in queue before dropping
this.batchSize = options.batchSize ?? 10; // Max batch size for request batching
this.batchTimeout = options.batchTimeout ?? 100; // Max wait time for batching (ms)
this.throttleMode = options.throttleMode ?? 'aggressive'; // 'aggressive' | 'conservative'
this.maxQueueSize = options.maxQueueSize ?? 100; // Maximum items in queue before dropping
this.priorityQueues = {
high: [],
medium: [],
Expand Down Expand Up @@ -45,6 +108,7 @@ class RateLimiter {
bucket = {
tokens: this.maxRequests - 1,
lastRefill: now,
endpointUsage: new Map()
};
this.buckets.set(identifier, bucket);

Expand Down
39 changes: 30 additions & 9 deletions src/lib/securityEvents.js → src/lib/securityEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ import {
AuditSeverity,
subscribeAudit,
} from '../utils/audit.js';
import type {
AuditSeverity as AuditSeverityType,
AuditCategory as AuditCategoryType,
AuditOutcome,
} from '../utils/audit.js';

export interface SecurityEventOptions {
actor?: string;
target?: string;
outcome?: AuditOutcome;
metadata?: Record<string, unknown>;
severityOverride?: AuditSeverityType;
}

interface SecurityAlert {
kind: string;
actor: string;
count: number;
windowMs: number;
action?: string;
}

// ─── Canonical security event types ──────────────────────────────────────────

Expand Down Expand Up @@ -91,31 +112,31 @@ function categoryFor(eventType) {

// ─── Anomaly detection state ─────────────────────────────────────────────────

const _failedAuthByActor = new Map(); // actor -> timestamps
const _ratelimitHitsByAction = new Map(); // action -> timestamps
const _failedAuthByActor = new Map<string, number[]>(); // actor -> timestamps
const _ratelimitHitsByAction = new Map<string, number[]>(); // action -> timestamps
const FAILED_AUTH_WINDOW_MS = 5 * 60_000;
const FAILED_AUTH_THRESHOLD = 5;
const RATE_LIMIT_WINDOW_MS = 10 * 60_000;
const RATE_LIMIT_THRESHOLD = 10;

const _alertSubscribers = new Set();
const _alertSubscribers = new Set<(alert: SecurityAlert) => void>();

export function subscribeSecurityAlerts(handler) {
export function subscribeSecurityAlerts(handler: (alert: SecurityAlert) => void) {
_alertSubscribers.add(handler);
return () => _alertSubscribers.delete(handler);
}

function emitAlert(alert) {
function emitAlert(alert: SecurityAlert) {
for (const fn of _alertSubscribers) {
try { fn(alert); } catch { /* swallow */ }
}
}

function pruneOlderThan(arr, cutoff) {
function pruneOlderThan(arr: number[], cutoff: number) {
return arr.filter((t) => t >= cutoff);
}

function checkAnomalies(eventType, payload) {
function checkAnomalies(eventType: string, payload: { actor?: string; metadata?: Record<string, unknown> }) {
const now = Date.now();

if (eventType === SecurityEventType.AUTH_LOGIN_FAILED) {
Expand Down Expand Up @@ -146,7 +167,7 @@ function checkAnomalies(eventType, payload) {
}

if (eventType === SecurityEventType.RATE_LIMIT_HIT) {
const action = payload.metadata?.action || 'unknown';
const action = typeof payload.metadata?.action === 'string' ? payload.metadata.action : 'unknown';
const list = pruneOlderThan(
_ratelimitHitsByAction.get(action) ?? [],
now - RATE_LIMIT_WINDOW_MS,
Expand Down Expand Up @@ -187,7 +208,7 @@ function checkAnomalies(eventType, payload) {
* }} [opts]
* @returns {Promise<object>}
*/
export function trackSecurityEvent(eventType, opts = {}) {
export function trackSecurityEvent(eventType: string, opts: SecurityEventOptions = {}) {
const severity = opts.severityOverride ?? SEVERITY_MAP[eventType] ?? AuditSeverity.INFO;
const category = categoryFor(eventType);

Expand Down
2 changes: 1 addition & 1 deletion src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'
import { Cache, TTL } from './cache.js'
import { rateLimiter } from './rateLimiter.js'
import auditTrail from './auditTrail.js'
import { getCircuitBreaker } from './errorHandling/CircuitBreaker'
import { getCircuitBreaker, type CircuitState } from './errorHandling/CircuitBreaker'

// ─── Cache setup ──────────────────────────────────────────────────────────────

Expand Down
58 changes: 37 additions & 21 deletions src/lib/storage.js → src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,33 @@ const STORES = {
OFFLINE_Q: 'offline-queue', // Queued writes for when back online
};

interface ApiCacheRecord {
key: string;
value: unknown;
expiresAt: number;
tag: string;
cachedAt: number;
}

interface OfflineOp {
id?: number;
type: string;
payload: unknown;
queuedAt: number;
}

// ─── DB open ──────────────────────────────────────────────────────────────────

let _db = null;
let _db: IDBDatabase | null = null;

function openDB() {
function openDB(): Promise<IDBDatabase> {
if (_db) return Promise.resolve(_db);

return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);

request.onupgradeneeded = (event) => {
const db = event.target.result;
const db = (event.target as IDBOpenDBRequest).result;

if (!db.objectStoreNames.contains(STORES.APP_STATE)) {
db.createObjectStore(STORES.APP_STATE);
Expand Down Expand Up @@ -66,18 +81,18 @@ function openDB() {

// ─── Generic transaction helper ───────────────────────────────────────────────

async function tx(storeName, mode, fn) {
const db = await openDB();
const trans = db.transaction(storeName, mode);
const store = trans.objectStore(storeName);
return new Promise((resolve, reject) => {
const req = fn(store);
async function tx<T = void>(storeName: string, mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T> | void): Promise<T | void> {
const db = await openDB();
const trans = db.transaction(storeName, mode);
const store = trans.objectStore(storeName);
return new Promise<T | void>((resolve, reject) => {
const req = fn(store) as IDBRequest<T> | void;
if (req && typeof req.onsuccess !== 'undefined') {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onerror = () => reject(req.error);
} else {
trans.oncomplete = () => resolve();
trans.onerror = () => reject(trans.error);
trans.onerror = () => reject(trans.error);
}
});
}
Expand Down Expand Up @@ -127,7 +142,7 @@ export async function clearStorage() {
*/
export async function getCachedApiResponse(key) {
try {
const record = await tx(STORES.API_CACHE, 'readonly', (s) => s.get(key));
const record = await tx<ApiCacheRecord | undefined>(STORES.API_CACHE, 'readonly', (s) => s.get(key));
if (!record) return null;
if (Date.now() > record.expiresAt) {
// Expired — delete lazily
Expand Down Expand Up @@ -173,9 +188,9 @@ export async function invalidateCacheByTag(tag) {

await new Promise((resolve, reject) => {
req.onsuccess = (e) => {
const cursor = e.target.result;
const cursor = (e.target as IDBRequest).result;
if (cursor) { cursor.delete(); cursor.continue(); }
else resolve();
else resolve(undefined);
};
req.onerror = () => reject(req.error);
});
Expand All @@ -195,9 +210,9 @@ export async function pruneExpiredApiCache() {

await new Promise((resolve, reject) => {
req.onsuccess = (e) => {
const cursor = e.target.result;
const cursor = (e.target as IDBRequest).result;
if (cursor) { cursor.delete(); cursor.continue(); }
else resolve();
else resolve(undefined);
};
req.onerror = () => reject(req.error);
});
Expand All @@ -210,27 +225,28 @@ export async function pruneExpiredApiCache() {
* Add an operation to the offline queue (e.g. a transaction to submit later).
* @param {{ type: string, payload: * }} op
*/
export async function enqueueOfflineOp(op) {
export async function enqueueOfflineOp(op: Omit<OfflineOp, 'queuedAt' | 'id'>) {
try {
await tx(STORES.OFFLINE_Q, 'readwrite', (s) => s.add({ ...op, queuedAt: Date.now() }));
} catch { /* ignore */ }
}

/**
* Read all queued offline operations.
* @returns {Promise<Array>}
* @returns {Promise<Array<OfflineOp>>}
*/
export async function getOfflineQueue() {
export async function getOfflineQueue(): Promise<OfflineOp[]> {
try {
return await tx(STORES.OFFLINE_Q, 'readonly', (s) => s.getAll()) ?? [];
const res = await tx<OfflineOp[]>(STORES.OFFLINE_Q, 'readonly', (s) => s.getAll());
return (res as OfflineOp[] | undefined) ?? [];
} catch { return []; }
}

/**
* Remove a processed operation from the queue by its auto-increment id.
* @param {number} id
*/
export async function dequeueOfflineOp(id) {
export async function dequeueOfflineOp(id: number) {
try {
await tx(STORES.OFFLINE_Q, 'readwrite', (s) => s.delete(id));
} catch { /* ignore */ }
Expand Down
Loading