From f6b17b390a5921352c58414ffab24d4e694a12ac Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Fri, 25 Sep 2026 19:36:41 +0100 Subject: [PATCH 1/4] fix: health payload includes indexer check --- backend/src/__tests__/issues711.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/__tests__/issues711.test.ts b/backend/src/__tests__/issues711.test.ts index 6bdaa11d1..0e47d2441 100644 --- a/backend/src/__tests__/issues711.test.ts +++ b/backend/src/__tests__/issues711.test.ts @@ -61,6 +61,7 @@ describe('#711 API contract schema snapshots', () => { databaseReplica: 'up', prisma: 'up', jobs: 'up', + indexer: 'up', }, sorobanCircuitBreaker: { state: 'closed', From e854810887faeaeb39b3f216ecfc36b0d784253e Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Fri, 25 Sep 2026 19:36:42 +0100 Subject: [PATCH 2/4] fix: validate prisma.eventOutbox init --- backend/src/eventOutbox.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 05dea308e..7d4774a10 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -124,6 +124,10 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { + if (!prisma.eventOutbox) { + throw new Error('prisma.eventOutbox is not initialized. Ensure Prisma schema includes eventOutbox model.'); + } + const now = new Date(); const record = await prisma.eventOutbox.create({ data: { From b3e33a2f39af9188b43a230eaa7068c0f3dc9cdf Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Fri, 25 Sep 2026 19:36:43 +0100 Subject: [PATCH 3/4] feat: restore idempotency store exports --- backend/src/idempotency.ts | 302 +++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index 1ab4b0448..fed3d4bc3 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -25,6 +25,8 @@ import crypto from 'crypto'; import { prisma } from './prisma'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; +import NodeCache from 'node-cache'; +import { redisClientManager } from './rateLimiter'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -376,6 +378,306 @@ declare global { } } +// ─── Error Classes ────────────────────────────────────────────────────── + +export class IdempotencyConflictError extends Error { + constructor(message = 'Idempotency key already used for a different request body') { + super(message); + this.name = 'IdempotencyConflictError'; + } +} + +// ─── Internal Types ───────────────────────────────────────────────────── + +export interface IdempotentOperationResult { + statusCode: number; + body: T; +} + +interface StoredResponse extends IdempotentOperationResult { + fingerprint: string; + metadata: IdempotencyKeyMetadata; +} + +interface PendingOperation { + fingerprint: string; + promise: Promise>; + metadata: IdempotencyKeyMetadata; +} + +export interface IdempotencyKeyInfo { + key: string; + metadata: IdempotencyKeyMetadata; +} + +export interface IdempotencyMetrics { + hits: number; + conflicts: number; + evictions: number; + activeKeys: number; + pendingKeys: number; +} + +// ─── Redis key prefix ─────────────────────────────────────────────────── + +const REDIS_PREFIX = 'idempotency:'; + +// ─── Fingerprint helper ───────────────────────────────────────────────── + +export function getIdempotencyHashThreshold(): number { + return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); +} + +export function buildIdempotencyFingerprint(payload: unknown): string { + const stable = stableStringify(payload); + const byteLength = Buffer.byteLength(stable, 'utf-8'); + if (byteLength > getIdempotencyHashThreshold()) { + return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; + } + return stable; +} + +function stableStringify(value: unknown): string { + if (value === null) return 'null'; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (typeof value !== 'object') return JSON.stringify(value); + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${serialized.join(',')}}`; +} + +// ─── IdempotencyStore ─────────────────────────────────────────────────── + +export class IdempotencyStore { + private readonly completedResponses: NodeCache; + private readonly pendingResponses = new Map>(); + + private _hits = 0; + private _conflicts = 0; + private _evictions = 0; + + constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { + const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); + this.completedResponses = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); + this.completedResponses.on('expired', (_key: string, _value: unknown) => { + this._evictions++; + }); + } + + private redisKey(key: string): string { + return `${REDIS_PREFIX}${key}`; + } + + private get redis() { + const client = redisClientManager.getClient(); + return redisClientManager.isReady() && client ? client : null; + } + + private async redisGet(key: string): Promise | null> { + const r = this.redis; + if (!r) return null; + try { + const raw = await r.get(this.redisKey(key)); + return raw ? (JSON.parse(raw) as StoredResponse) : null; + } catch { + return null; + } + } + + private async redisSet(key: string, value: StoredResponse): Promise { + const r = this.redis; + if (!r) return; + try { + const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); + await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); + } catch (err) { + console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); + } + } + + private async redisDel(key: string): Promise { + const r = this.redis; + if (!r) return false; + try { + return (await r.del(this.redisKey(key))) > 0; + } catch { + return false; + } + } + + async execute( + key: string, + fingerprint: string, + operation: () => Promise> + ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { + const now = new Date().toISOString(); + + let completed = await this.redisGet(key); + if (!completed) { + completed = this.completedResponses.get>(key) ?? null; + } + + if (completed) { + if (completed.fingerprint !== fingerprint) { + this._conflicts++; + throw new IdempotencyConflictError(); + } + this._hits++; + completed.metadata.lastAccessedAt = now; + completed.metadata.replayCount++; + await this.redisSet(key, completed); + this.completedResponses.set(key, completed); + return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; + } + + const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; + if (pendingOperation) { + if (pendingOperation.fingerprint !== fingerprint) { + this._conflicts++; + throw new IdempotencyConflictError(); + } + this._hits++; + pendingOperation.metadata.lastAccessedAt = now; + pendingOperation.metadata.replayCount++; + const replayed = await pendingOperation.promise; + return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; + } + + const metadata: IdempotencyKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; + + const operationPromise = (async () => { + const result = await operation(); + const stored: StoredResponse = { + ...result, + fingerprint, + metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, + }; + await this.redisSet(key, stored); + this.completedResponses.set(key, stored, this.ttlMs / 1000); + return stored; + })(); + + this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); + + try { + const stored = await operationPromise; + return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; + } finally { + this.pendingResponses.delete(key); + } + } + + inspectKeys(prefix?: string): IdempotencyKeyInfo[] { + const results: IdempotencyKeyInfo[] = []; + for (const key of this.completedResponses.keys()) { + if (prefix && !key.startsWith(prefix)) continue; + const entry = this.completedResponses.get>(key); + if (entry) results.push({ key, metadata: { ...entry.metadata } }); + } + for (const [key, pending] of this.pendingResponses.entries()) { + if (prefix && !key.startsWith(prefix)) continue; + if (!results.some((r) => r.key === key)) { + results.push({ key, metadata: { ...pending.metadata } }); + } + } + return results; + } + + async deleteKey(key: string): Promise { + const deletedLocal = this.completedResponses.del(key) > 0; + const deletedPending = this.pendingResponses.delete(key); + const deletedRedis = await this.redisDel(key); + if (deletedLocal || deletedPending || deletedRedis) { + this._evictions++; + return true; + } + return false; + } + + clear(): void { + const count = this.completedResponses.keys().length + this.pendingResponses.size; + this._evictions += count; + this.completedResponses.flushAll(); + this.pendingResponses.clear(); + } + + getMetrics(): IdempotencyMetrics { + return { + hits: this._hits, + conflicts: this._conflicts, + evictions: this._evictions, + activeKeys: this.completedResponses.keys().length, + pendingKeys: this.pendingResponses.size, + }; + } + + async pruneStaleKeys( + retentionMs: number, + dryRun = false, + ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { + const cutoff = Date.now() - retentionMs; + let localPruned = 0; + let redisPruned = 0; + + for (const key of this.completedResponses.keys()) { + const entry = this.completedResponses.get>(key); + if (!entry) continue; + const createdAt = Date.parse(entry.metadata.createdAt); + if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; + if (!dryRun) { + this.completedResponses.del(key); + this._evictions++; + } + localPruned++; + } + + const r = this.redis; + if (r) { + let cursor = '0'; + do { + const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); + cursor = nextCursor; + for (const redisKey of keys) { + try { + const raw = await r.get(redisKey); + if (!raw) continue; + const entry = JSON.parse(raw) as StoredResponse; + const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); + const ttl = await r.ttl(redisKey); + const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; + if (!isStale) continue; + if (!dryRun) { + await r.del(redisKey); + this._evictions++; + } + redisPruned++; + } catch { + if (!dryRun) { + await r.del(redisKey); + this._evictions++; + } + redisPruned++; + } + } + } while (cursor !== '0'); + } + + return { pruned: localPruned + redisPruned, localPruned, redisPruned }; + } +} + +// ─── Singleton ────────────────────────────────────────────────────────── + +export const idempotencyStore = new IdempotencyStore( + parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) +); + // ─── Cleanup ──────────────────────────────────────────────────────────────── /** From 8a83d7fceedc42bb545f731be57a34ec413e72be Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Fri, 25 Sep 2026 19:36:45 +0100 Subject: [PATCH 4/4] fix: validate cache hook return type --- backend/src/middleware/cache.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index 9182b1dac..d23b7917c 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -332,6 +332,18 @@ export function triggerCacheInvalidation( for (const hook of invalidationHooks) { try { const hookPatterns = hook(eventType, metadata); + // Validate and sanitize hook return value + if (!Array.isArray(hookPatterns)) { + console.error( + JSON.stringify({ + level: 'error', + event: 'invalidation_hook_error', + error: `InvalidationHook must return string[], got ${typeof hookPatterns}`, + eventType, + }), + ); + continue; + } patterns.push(...hookPatterns); } catch (err) { console.error(