From e428d427d367a66e6116e6040f72a68bf3d643a1 Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:25 +0100 Subject: [PATCH 1/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/app.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 4eb7a2cb..2cf87af4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,7 +9,62 @@ import { getMetrics, httpRequestDuration } from './metrics'; import { buildCorsOptions } from './middleware/corsOptions'; import { runDeepHealthCheck } from './services/deepHealth'; -import { +import * as bountyStore from './services/bountyStore'; +import { EventEmitter } from 'node:events'; + +interface BountyEvent { + type: string; + bountyId?: string; + maintainer?: string; + status?: string; + timestamp: number; +} + +interface BountyEventRecord { + id: number; + event: BountyEvent; +} + +const bountyEventBus = new EventEmitter(); +const bountyEventLog: BountyEventRecord[] = []; +let bountyEventSeq = 0; + +function publishBountyEvent(event: BountyEvent): void { + const id = ++bountyEventSeq; + const record: BountyEventRecord = { id, event }; + bountyEventLog.push(record); + if (bountyEventLog.length > 1000) { + bountyEventLog.shift(); + } + bountyEventBus.emit('bounty-event', record); +} + +function emitBountyEvent(type: string, result: unknown, args: any[]): void { + const data = result && typeof result === 'object' && 'data' in result ? (result as any).data : result; + const bountyId = data?.id ?? (typeof args[0] === 'string' ? args[0] : undefined); + const maintainer = + data?.maintainer ?? + (typeof args[1] === 'object' && args[1] ? (args[1] as any).maintainer : undefined) ?? + (typeof args[2] === 'object' && args[2] ? (args[2] as any).maintainer : undefined); + const status = data?.status; + publishBountyEvent({ type, bountyId, maintainer, status, timestamp: Date.now() }); +} + +function wrapBountyMutation any>(fn: T, type: string): T { + return ((...args: any[]) => { + const maybePromise = fn.apply(bountyStore, args); + if (maybePromise && typeof maybePromise.then === 'function') { + return maybePromise.then((result: any) => { + emitBountyEvent(type, result, args); + return result; + }); + } + emitBountyEvent(type, maybePromise, args); + return maybePromise; + }) as T; +} + +const { createBounty, disputeBounty, extendDeadline, @@ -31,7 +86,19 @@ import { getGlobalMetricsCached, getLeaderboard, aggregatedMetrics, -} from './services/bountyStore'; +} = { + ...bountyStore, + createBounty: wrapBountyMutation(bountyStore.createBounty, 'bounty.created'), + disputeBounty: wrapBountyMutation(bountyStore.disputeBounty, 'bounty.disputed'), + extendDeadline: wrapBountyMutation(bountyStore.extendDeadline, 'bounty.deadline_extended'), + resolveDisputeBounty: wrapBountyMutation(bountyStore.resolveDisputeBounty, 'bounty.dispute_resolved'), + updateBountyNotes: wrapBountyMutation(bountyStore.updateBountyNotes, 'bounty.notes_updated'), + refundBounty: wrapBountyMutation(bountyStore.refundBounty, 'bounty.refunded'), + cancelBounty: wrapBountyMutation(bountyStore.cancelBounty, 'bounty.cancelled'), + releaseBounty: wrapBountyMutation(bountyStore.releaseBounty, 'bounty.released'), + reserveBounty: wrapBountyMutation(bountyStore.reserveBounty, 'bounty.reserved'), + submitBounty: wrapBountyMutation(bountyStore.submitBounty, 'bounty.submitted'), +}; import { listOpenIssues } from './services/openIssues'; @@ -333,6 +400,44 @@ app.get('/api/bounties/by-issue', (req: Request, res: Response) => { return res.json({ data: found }); }); +app.get('/api/bounties/stream', (req: Request, res: Response) => { + const bountyId = typeof req.query.bountyId === 'string' ? req.query.bountyId : undefined; + const maintainer = typeof req.query.maintainer === 'string' ? req.query.maintainer : undefined; + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.write('retry: 3000\n\n'); + + const send = (record: BountyEventRecord) => { + if (bountyId && record.event.bountyId !== bountyId) return; + if (maintainer && record.event.maintainer !== maintainer) return; + res.write(`id: ${record.id}\n`); + res.write(`event: bounty\n`); + res.write(`data: ${JSON.stringify(record.event)}\n\n`); + }; + + const listener = (record: BountyEventRecord) => send(record); + bountyEventBus.on('bounty-event', listener); + + const lastEventId = parseInt(req.headers['last-event-id']?.toString() ?? '0', 10); + if (lastEventId > 0) { + for (const record of bountyEventLog) { + if (record.id > lastEventId) { + send(record); + } + } + } + + const heartbeat = setInterval(() => res.write(': ping\n\n'), 30000); + req.on('close', () => { + clearInterval(heartbeat); + bountyEventBus.off('bounty-event', listener); + res.end(); + }); +}); + app.get('/api/bounties', async (req: Request, res: Response) => { try { const q = typeof req.query.q === 'string' ? req.query.q : undefined; From c2995b0185f78bc67626028bf8fe73fe3af41229 Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:27 +0100 Subject: [PATCH 2/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/services/bountyStore.ts | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/backend/src/services/bountyStore.ts b/backend/src/services/bountyStore.ts index dffaede7..7c8ccecf 100644 --- a/backend/src/services/bountyStore.ts +++ b/backend/src/services/bountyStore.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { EventEmitter } from "node:events"; import lockfile from "proper-lockfile"; import { sendNotification, @@ -93,6 +94,38 @@ export interface BountyAuditLogRecord { metadata?: Record; } +export interface BountyStatusChangeEvent { + /** Unique event identifier (monotonic, usable as SSE Last-Event-ID). */ + id: string; + /** The event type discriminator. */ + type: "bounty_status_changed"; + /** The bounty whose status changed. */ + bountyId: string; + /** Maintainer address of the bounty, used for filtered streams. */ + maintainer: string; + /** The status before the change. */ + fromStatus: BountyStatus; + /** The status after the change. */ + toStatus: BountyStatus; + /** Unix timestamp in seconds when the transition occurred. */ + timestamp: number; + /** Address or system actor that triggered the change. */ + actor?: string; + /** Additional structured event context. */ + metadata?: Record; +} + +export interface BountyEventFilter { + /** Only include events for this bounty ID. */ + bountyId?: string; + /** Only include events whose maintainer matches this address. */ + maintainer?: string; + /** Replay only events after this event ID (SSE Last-Event-ID). */ + sinceId?: string; + /** Replay only events after this Unix timestamp (seconds). */ + since?: number; +} + /** * Represents a complete bounty record stored in the database. */ @@ -293,6 +326,91 @@ function nowInSeconds(): number { return Math.floor(Date.now() / 1000); } +const bountyEventEmitter = new EventEmitter(); +bountyEventEmitter.setMaxListeners(0); + +const BOUNTY_EVENT_HISTORY_LIMIT = 100; +const BOUNTY_EVENT_HISTORY_TTL_MS = 5 * 60 * 1000; +let bountyEventSequence = 0; +const bountyEventHistory: BountyStatusChangeEvent[] = []; + +function eventMatchesFilter( + event: BountyStatusChangeEvent, + filter: BountyEventFilter, +): boolean { + if (filter.bountyId && event.bountyId !== filter.bountyId) { + return false; + } + if (filter.maintainer && event.maintainer !== filter.maintainer) { + return false; + } + if (filter.sinceId) { + const sinceSequence = Number(filter.sinceId.replace("evt-", "")); + const eventSequence = Number(event.id.replace("evt-", "")); + if ( + Number.isFinite(sinceSequence) && + Number.isFinite(eventSequence) && + eventSequence <= sinceSequence + ) { + return false; + } + } + if (filter.since !== undefined && event.timestamp <= filter.since) { + return false; + } + return true; +} + +function publishBountyStatusChange( + input: Omit, +): void { + const id = `evt-${++bountyEventSequence}`; + const event: BountyStatusChangeEvent = { + id, + type: "bounty_status_changed", + ...input, + }; + + bountyEventHistory.push(event); + const now = Date.now(); + while ( + bountyEventHistory.length > BOUNTY_EVENT_HISTORY_LIMIT || + (bountyEventHistory.length > 0 && + now - bountyEventHistory[0].timestamp * 1000 > + BOUNTY_EVENT_HISTORY_TTL_MS) + ) { + bountyEventHistory.shift(); + } + + bountyEventEmitter.emit("event", event); +} + +export function getBountyEventHistory( + filter: BountyEventFilter = {}, +): BountyStatusChangeEvent[] { + return bountyEventHistory.filter((event) => + eventMatchesFilter(event, filter), + ); +} + +export function subscribeBountyEvents( + listener: (event: BountyStatusChangeEvent) => void, + filter: BountyEventFilter = {}, +): { close: () => void } { + const handler = (event: BountyStatusChangeEvent) => { + if (eventMatchesFilter(event, filter)) { + listener(event); + } + }; + + bountyEventEmitter.on("event", handler); + return { + close: () => { + bountyEventEmitter.off("event", handler); + }, + }; +} + function ensureStore(): void { const storePath = getStorePath(); fs.mkdirSync(path.dirname(storePath), { recursive: true }); @@ -471,6 +589,27 @@ function normalizeRecords(records: BountyRecord[]): BountyRecord[] { if (changed) { writeStore(next); appendAuditLogs(auditEntries); + + for (let i = 0; i < records.length; i++) { + const before = records[i]; + const after = next[i]; + if (before.status !== after.status) { + publishBountyStatusChange({ + bountyId: after.id, + maintainer: after.maintainer, + fromStatus: before.status, + toStatus: after.status, + timestamp: now, + actor: "system", + metadata: { + reason: + after.status === "expired" + ? "deadline_passed" + : "reservation_timeout", + }, + }); + } + } } return next; } @@ -495,10 +634,25 @@ function persistUpdated( records: BountyRecord[], updated: BountyRecord, ): BountyRecord { + const previous = records.find((record) => record.id === updated.id); const next = records.map((record) => record.id === updated.id ? updated : record, ); writeStore(next); + + if (previous && previous.status !== updated.status) { + const lastEvent = updated.events[updated.events.length - 1]; + publishBountyStatusChange({ + bountyId: updated.id, + maintainer: updated.maintainer, + fromStatus: previous.status, + toStatus: updated.status, + timestamp: lastEvent?.timestamp ?? nowInSeconds(), + actor: lastEvent?.actor, + metadata: lastEvent?.details, + }); + } + return updated; } From cdb22bb83b793c0b3ba684301ab42f308fad2ab7 Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:29 +0100 Subject: [PATCH 3/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/docs/openapi.ts | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend/src/docs/openapi.ts b/backend/src/docs/openapi.ts index f0b5a19a..1360b70d 100644 --- a/backend/src/docs/openapi.ts +++ b/backend/src/docs/openapi.ts @@ -148,6 +148,69 @@ registry.registerPath({ }, }); +const bountyStatusEventSchema = z + .object({ + id: z.string().openapi({ + description: "Opaque, monotonic event ID. Pass this as Last-Event-ID when reconnecting to replay missed events.", + example: "evt_000001", + }), + event: z.enum(["bounty.status.changed"]).openapi({ + description: "Event type.", + example: "bounty.status.changed", + }), + data: z.object({ + bountyId: z.string().openapi({ description: "Bounty ID.", example: "BNT-0001" }), + fromStatus: z.string().openapi({ description: "Previous status.", example: "open" }), + toStatus: z.string().openapi({ description: "New status.", example: "reserved" }), + maintainer: z.string().openapi({ description: "Maintainer Stellar address.", example: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }), + updatedAt: z.string().openapi({ description: "ISO 8601 timestamp.", example: "2025-01-01T00:00:00.000Z" }), + }), + }) + .openapi("BountyStatusEvent"); + +registry.register("BountyStatusEvent", bountyStatusEventSchema); + +registry.registerPath({ + method: "get", + path: "/api/bounties/stream", + tags: ["Bounties"], + summary: "Stream bounty status changes (SSE)", + description: + "Opens a Server-Sent Events (SSE) stream that pushes a `bounty.status.changed` event " + + "whenever a subscribed bounty changes status. Pass `bountyId` and/or `maintainer` query " + + "parameters to filter events. Clients can reconnect and send `Last-Event-ID` to backfill any " + + "events missed while disconnected.", + request: { + query: z.object({ + bountyId: z.string().optional().openapi({ + description: "Only receive events for this bounty ID.", + example: "BNT-0001", + }), + maintainer: z.string().optional().openapi({ + description: "Only receive events for bounties maintained by this Stellar address.", + example: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + }), + }), + headers: z.object({ + "Last-Event-ID": z.string().optional().openapi({ + description: "Event ID of the last received event. The server replays events after this ID before opening the live stream.", + example: "evt_000001", + }), + }), + }, + responses: { + 200: { + description: "SSE stream. Each message is sent as `text/event-stream` with `id`, `event`, and `data` fields.", + content: { + "text/event-stream": { + schema: bountyStatusEventSchema, + }, + }, + }, + 400: errorResponse("Invalid query parameters."), + }, +}); + registry.registerPath({ method: "get", From cb5bb9bb1c5e8e38b0c429563f8e51bbdb605ee9 Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:30 +0100 Subject: [PATCH 4/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/validation/schemas.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/backend/src/validation/schemas.ts b/backend/src/validation/schemas.ts index 4fe658ef..93188436 100644 --- a/backend/src/validation/schemas.ts +++ b/backend/src/validation/schemas.ts @@ -260,6 +260,46 @@ export const bountyEventSchema = z.object({ details: z.record(z.any()).optional(), }); +export const bountyStatusChangeEventSchema = z + .object({ + id: z.string().min(1).openapi({ + example: 'evt_01J0ABCDEFGHIJKLMNOPQRST', + description: 'Unique SSE event id used for Last-Event-ID reconnects.', + }), + event: z.literal('bounty.status_changed').openapi({ + example: 'bounty.status_changed', + description: 'SSE event type for bounty status transitions.', + }), + data: z + .object({ + bountyId: bountyIdSchema, + maintainer: stellarAccountSchema, + event: bountyEventSchema, + }) + .openapi('BountyStatusChangeData'), + }) + .openapi('BountyStatusChangeEvent'); + +export const bountyStreamQuerySchema = z + .object({ + bountyId: bountyIdSchema.optional().openapi({ + description: 'Only stream events for this bounty ID.', + }), + maintainer: stellarAccountSchema.optional().openapi({ + description: 'Only stream events for bounties maintained by this Stellar address.', + }), + since: z.coerce + .number() + .int() + .min(0) + .optional() + .openapi({ + example: 1710000000, + description: 'Replay events after this Unix timestamp (seconds) on reconnect/backfill.', + }), + }) + .openapi('BountyStreamQuery'); + export const bountyRecordSchema = z .object({ id: z.string().openapi({ example: 'BNT-0001' }), From f08b6cfa1082596fb9a95ac6a50bb4c28ac80585 Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:32 +0100 Subject: [PATCH 5/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/services/eventBus.ts | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 backend/src/services/eventBus.ts diff --git a/backend/src/services/eventBus.ts b/backend/src/services/eventBus.ts new file mode 100644 index 00000000..1f68a8f0 --- /dev/null +++ b/backend/src/services/eventBus.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; + +export interface StreamFilter { + bountyId?: string; + maintainerAddress?: string; +} + +interface Subscriber { + id: string; + filters: StreamFilter; + send: (chunk: string) => void; +} + +export interface BusEvent { + id: number; + event: string; + payload: Record; + bountyId?: string; + maintainerAddress?: string; + timestamp: number; +} + +const HISTORY_LIMIT = 100; + +export class EventBus { + private subscribers = new Set(); + private history: BusEvent[] = []; + private nextId = 1; + + subscribe(filters: StreamFilter, send: (chunk: string) => void): () => void { + const id = randomUUID(); + const subscriber: Subscriber = { id, filters, send }; + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + publish(event: string, payload: Record, meta?: { bountyId?: string; maintainerAddress?: string }): void { + const bountyId = meta?.bountyId ?? (typeof payload.bountyId === "string" ? payload.bountyId : undefined); + const maintainerAddress = + meta?.maintainerAddress ?? + (typeof payload.maintainerAddress === "string" ? payload.maintainerAddress : undefined) ?? + (typeof payload.maintainer === "string" ? payload.maintainer : undefined); + + const busEvent: BusEvent = { + id: this.nextId++, + event, + payload, + bountyId, + maintainerAddress, + timestamp: Date.now(), + }; + + this.history.push(busEvent); + if (this.history.length > HISTORY_LIMIT) { + this.history.shift(); + } + + for (const subscriber of this.subscribers) { + if (matches(subscriber.filters, busEvent)) { + const chunk = `id: ${busEvent.id}\nevent: ${busEvent.event}\ndata: ${JSON.stringify(busEvent.payload)}\n\n`; + try { + subscriber.send(chunk); + } catch (err) { + this.subscribers.delete(subscriber); + } + } + } + } + + getHistorySince(sinceId: number, filters?: StreamFilter): BusEvent[] { + return this.history.filter((event) => event.id > sinceId && (!filters || matches(filters, event))); + } +} + +function matches(filters: StreamFilter, event: BusEvent): boolean { + if (filters.bountyId && event.bountyId && filters.bountyId !== event.bountyId) return false; + if (filters.maintainerAddress && event.maintainerAddress && filters.maintainerAddress !== event.maintainerAddress) return false; + return true; +} + +export const eventBus = new EventBus(); \ No newline at end of file From fe0bf332ebdcab655ff3f7a4f841f17dfcf7555c Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:33 +0100 Subject: [PATCH 6/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/routes/bounties.ts | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/backend/src/routes/bounties.ts b/backend/src/routes/bounties.ts index e69de29b..913ed58a 100644 --- a/backend/src/routes/bounties.ts +++ b/backend/src/routes/bounties.ts @@ -0,0 +1,53 @@ +import { Router, type Request, type Response } from "express"; +import { eventBus, type StreamFilter } from "../services/eventBus"; + +const router = Router(); + +router.get("/stream", (req: Request, res: Response) => { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + + const filters: StreamFilter = {}; + if (typeof req.query.bountyId === "string" && req.query.bountyId) { + filters.bountyId = req.query.bountyId; + } + if (typeof req.query.maintainerAddress === "string" && req.query.maintainerAddress) { + filters.maintainerAddress = req.query.maintainerAddress; + } + + const lastEventIdHeader = req.headers["last-event-id"]; + const lastEventId = + typeof lastEventIdHeader === "string" + ? Number(lastEventIdHeader) + : typeof req.query.lastEventId === "string" + ? Number(req.query.lastEventId) + : 0; + const sinceId = Number.isFinite(lastEventId) && lastEventId > 0 ? lastEventId : 0; + + res.write(": connected\n\n"); + + if (sinceId > 0) { + const missedEvents = eventBus.getHistorySince(sinceId, filters); + for (const ev of missedEvents) { + res.write(`id: ${ev.id}\nevent: ${ev.event}\ndata: ${JSON.stringify(ev.payload)}\n\n`); + } + } + + const unsubscribe = eventBus.subscribe(filters, (chunk: string) => { + res.write(chunk); + }); + + const heartbeat = setInterval(() => { + res.write(": ping\n\n"); + }, 30000); + + req.on("close", () => { + clearInterval(heartbeat); + unsubscribe(); + res.end(); + }); +}); + +export default router; \ No newline at end of file From a4f2196b921830b40c730dbf4acc83b1f9f05e3e Mon Sep 17 00:00:00 2001 From: Moshood Mohammed Date: Fri, 28 Aug 2026 21:42:34 +0100 Subject: [PATCH 7/7] feat: [FEATURE] Add WebSocket/SSE push channel for bounty status u (#790) --- backend/src/config.ts | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/backend/src/config.ts b/backend/src/config.ts index 5a490eeb..3c9c5c77 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -12,7 +12,7 @@ import { getTokenAddressMap } from './utils'; export interface PublicConfig { /** * Protocol fee in basis points (100 bps = 1 %). - * Matches the `protocol_fee_bps` argument accepted by the Soroban contract's + * Matches the `protocol_fee_bps` argument accepted by the Soroban contract's * `create_bounty` instruction. 0 = no protocol fee. */ feeBps: number; @@ -26,13 +26,13 @@ export interface PublicConfig { /** * Minimum bounty amount in the bounty token. - * Enforced by `validateBountyAmount` in the API layer. + * Enforced by validateBountyAmountin the API layer. */ minBountyAmount: number; /** * Maximum bounty amount in the bounty token. - * Enforced by `validateBountyAmount` in the API layer. + * Enforced by validateBountyAmountin the API layer. */ maxBountyAmount: number; @@ -50,6 +50,19 @@ export interface PublicConfig { */ defaultReservationTtlSeconds: number; + /** + * How long (in seconds) past bounty status-change events are retained for + * SSE/WebSocket clients that reconnect after a short disconnect. + * Used by the event stream to backfill missed events. + */ + eventBackfillWindowSeconds: number; + + /** + * Interval (milliseconds) between SSE keep-alive comment pings. Sent + * to connected clients to prevent proxies from closing idle connections. + */ + eventStreamPingIntervalMs: number; + /** * Soroban network the backend is connected to (e.g. "testnet", "futurenet", * "mainnet"). Derived from `SOROBAN_NETWORK_PASSPHRASE` when set; falls @@ -64,14 +77,14 @@ function resolveNetworkLabel(): string { if (passphrase.includes('Public Global')) return 'mainnet'; if (passphrase.includes('Test SDF Network')) return 'testnet'; if (passphrase.includes('Test SDF Future Network')) return 'futurenet'; - return process.env.STELLAR_NETWORK ?? 'futurenet'; + return process.env.STELLARNETWORK ?? 'futurenet'; } /** * Build the public config object from environment variables. * * Sensitive variables (GITHUB_WEBHOOK_SECRET, DATABASE_URL, ADMIN_API_KEY_HASH, - * MAINTAINER_PUBLIC_KEY, SENDGRID_API_KEY, etc.) are never included. + * MAINTAINER_PUBLIC_KEY, SENDGRIF_API_KEY, etc.) are never included. */ export function getPublicConfig(): PublicConfig { const feeBps = (() => { @@ -109,6 +122,21 @@ export function getPublicConfig(): PublicConfig { return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 86_400) : 604_800; })(); + // New SSE/backfill configuration with safe defaults + const eventBackfillWindowSeconds = (() => { + const raw = process.env.EVENT_BACKFILL_WINDOW_SECONDS; + if (!raw) return 300; // 5 minutes + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 300; + })(); + + const eventStreamPingIntervalMs = (() => { + const raw = process.env.EVENT_STREAM_PING_INTERVAL_MS; + if (!raw) return 15_000; // 15 seconds + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 15_000; + })(); + // Build the token map — only expose symbols that are in the allowlist. const allowedSymbols = (() => { const configured = process.env.ALLOWED_TOKEN_SYMBOLS?.split(',') @@ -133,6 +161,8 @@ export function getPublicConfig(): PublicConfig { maxBountyAmount, supportedTokens, defaultReservationTtlSeconds, + eventBackfillWindowSeconds, + eventStreamPingIntervalMs, network: resolveNetworkLabel(), }; }