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
24 changes: 24 additions & 0 deletions comebackhere-backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Dependencies and build output are produced inside the image
node_modules
dist
coverage

# Tests are not part of the runtime image
src/tests
**/*.test.ts
**/*.spec.ts

# Local environment files — pass config at runtime (--env-file / -e) instead
.env
.env.*
!.env.example

# Tooling / VCS noise
.git
.gitignore
.vscode
.idea
*.log
npm-debug.log*
Dockerfile
.dockerignore
49 changes: 49 additions & 0 deletions comebackhere-backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
#
# comebackhere-backend (Node) — multi-stage image
#
# docker build -t comebackhere-backend comebackhere-backend/
# docker run --rm -p 3000:3000 --env-file comebackhere-backend/.env comebackhere-backend
#
# package-lock.json is gitignored in this repo, so installs use `npm ci` when a
# lockfile is present in the build context and fall back to `npm install`.

ARG NODE_VERSION=20

# ── deps: production dependencies only ────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi \
&& npm cache clean --force

# ── builder: full dependencies + TypeScript compile ───────────────────────────
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build

# ── runtime: dist/ + production node_modules, non-root ────────────────────────
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./

# The official image ships an unprivileged `node` user (uid 1000).
USER node

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT}/health" > /dev/null || exit 1

# Run node directly (not via npm) so SIGTERM reaches the app's graceful
# shutdown handler, which drains webhooks within SHUTDOWN_TIMEOUT_MS.
CMD ["node", "dist/index.js"]
2 changes: 1 addition & 1 deletion comebackhere-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
Expand Down
61 changes: 32 additions & 29 deletions comebackhere-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
/**
* Backend entrypoint — Issue #219
*
* Handles SIGTERM and SIGINT for graceful shutdown:
* 1. Stops accepting new connections (server.close)
* 2. Stops the treasury indexer poll loop
* 3. Closes the MongoDB connection
* 4. Applies a hard-timeout safety net so the process always exits
* Handles SIGTERM and SIGINT for graceful shutdown (see ./shutdown.ts):
* stops accepting HTTP connections and webhook jobs, stops the indexers,
* drains in-flight webhook deliveries (persisting unfinished ones for retry),
* closes MongoDB, and applies a hard-timeout safety net.
*/

import { createApp } from "./app.js"
import { startTreasuryIndexer, stopTreasuryIndexer } from "./services/treasury-indexer.js"
import { stopIndexer } from "./indexer.js"
import { stopComplianceIndexer } from "./services/compliance-indexer.js"
import { closeMongo } from "./db/mongo.js"
import { webhookDeliveryQueue } from "./services/webhook-delivery.js"
import { createShutdownHandler, resolveWebhookDrainTimeout } from "./shutdown.js"
import { validateEnv } from "./lib/env.js"
import type { Server } from "http"

// Fail fast on missing variables or malformed Stellar ids, naming the variable.
try {
validateEnv(process.env)
} catch (err) {
console.error(`[startup] ${err instanceof Error ? err.message : err}`)
process.exit(1)
}

const PORT = process.env.PORT ?? "3000"
/** Hard shutdown timeout in ms — forces exit if clean shutdown hangs. */
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS ?? "10000")
/** Max time to wait for in-flight webhook deliveries; capped below SHUTDOWN_TIMEOUT_MS. */
const WEBHOOK_DRAIN_TIMEOUT_MS = resolveWebhookDrainTimeout(
Number(process.env.WEBHOOK_DRAIN_TIMEOUT_MS ?? "5000"),
SHUTDOWN_TIMEOUT_MS,
)

const app = createApp()
startTreasuryIndexer()

// Retry deliveries that a previous process persisted during shutdown.
webhookDeliveryQueue.resumePending().catch((err: unknown) => {
console.error(
"[webhook] could not resume persisted deliveries:",
err instanceof Error ? err.message : err,
)
})

const server: Server = app.listen(Number(PORT), () => {
console.log(`comebackhere-backend listening on port ${PORT}`)
})
Expand All @@ -30,30 +53,10 @@ const server: Server = app.listen(Number(PORT), () => {
// Graceful shutdown
// ---------------------------------------------------------------------------

let shuttingDown = false

async function shutdown(signal: string): Promise<void> {
if (shuttingDown) return
shuttingDown = true

console.log(`[shutdown] received ${signal} — starting graceful shutdown`)

// Hard-timeout safety net: if clean shutdown takes too long, force exit.
const hardTimeout = setTimeout(() => {
console.error("[shutdown] hard timeout reached — forcing exit")
process.exit(1)
}, SHUTDOWN_TIMEOUT_MS)
// Allow the process to exit even if the timer is still pending.
hardTimeout.unref()

try {
// 1. Stop accepting new HTTP connections; wait for in-flight requests.
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()))
})
console.log("[shutdown] HTTP server closed")

// 2. Stop indexer poll loops.
const shutdown = createShutdownHandler({
server,
webhookQueue: webhookDeliveryQueue,
stopIndexers: () => {
stopTreasuryIndexer()
stopIndexer()
stopComplianceIndexer()
Expand Down
59 changes: 59 additions & 0 deletions comebackhere-backend/src/lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,62 @@ export async function cacheSet(key: string, value: unknown, ttlSec = 30): Promis
// silently fail – cache is optional
}
}

// ---------------------------------------------------------------------------
// In-process TTL cache with explicit invalidation
// ---------------------------------------------------------------------------

interface MemoryCacheEntry {
value: unknown
expiresAt: number
}

const _memoryCache = new Map<string, MemoryCacheEntry>()

/** Returns the cached value for `key` if present and not yet expired. */
export function memoryCacheGet<T>(key: string): T | null {
const entry = _memoryCache.get(key)
if (!entry) return null
if (Date.now() >= entry.expiresAt) {
_memoryCache.delete(key)
return null
}
return entry.value as T
}

/** Stores `value` under `key`; the TTL is a safety net for changes nobody invalidates. */
export function memoryCacheSet(key: string, value: unknown, ttlMs: number): void {
_memoryCache.set(key, { value, expiresAt: Date.now() + ttlMs })
}

/**
* Removes `key` from the in-process cache and logs the invalidation.
* Returns true when a live entry was evicted.
*/
export function invalidateCacheKey(key: string, reason = "manual"): boolean {
const existed = _memoryCache.delete(key)
console.log(`[cache] invalidated key=${key} reason=${reason} evicted=${existed}`)
return existed
}

// ---------------------------------------------------------------------------
// Treasury balances cache (GET /api/treasury/balances)
// ---------------------------------------------------------------------------

export type TreasuryBalances = Array<{ token: string; balance: string }>

export const BALANCE_CACHE_KEY = "treasury:balances"
export const BALANCE_CACHE_TTL_MS = 5_000

export function getBalanceCache(): TreasuryBalances | null {
return memoryCacheGet<TreasuryBalances>(BALANCE_CACHE_KEY)
}

export function setBalanceCache(data: TreasuryBalances): void {
memoryCacheSet(BALANCE_CACHE_KEY, data, BALANCE_CACHE_TTL_MS)
}

/** Evicts cached balances so the next GET /balances re-reads them from RPC. */
export function invalidateBalanceCache(reason?: string): boolean {
return invalidateCacheKey(BALANCE_CACHE_KEY, reason)
}
2 changes: 1 addition & 1 deletion comebackhere-backend/src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "stellar-sdk"

export type SorobanClient = {
getAccount: (publicKey: string) => Promise<Parameters<TransactionBuilder["constructor"]>[0]>
getAccount: (publicKey: string) => Promise<ConstructorParameters<typeof TransactionBuilder>[0]>
simulateTransaction: (
tx: Parameters<SorobanRpc.Server["simulateTransaction"]>[0],
) => ReturnType<SorobanRpc.Server["simulateTransaction"]>
Expand Down
2 changes: 1 addition & 1 deletion comebackhere-backend/src/routes/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ router.get("/audit", validateQuery(complianceAuditQuerySchema), async (req: Requ
// ---------------------------------------------------------------------------

export type SorobanClient = {
getAccount: (publicKey: string) => Promise<Parameters<TransactionBuilder["constructor"]>[0]>
getAccount: (publicKey: string) => Promise<ConstructorParameters<typeof TransactionBuilder>[0]>
simulateTransaction: (tx: Parameters<SorobanRpc.Server["simulateTransaction"]>[0]) => ReturnType<SorobanRpc.Server["simulateTransaction"]>
sendTransaction: (tx: Parameters<SorobanRpc.Server["sendTransaction"]>[0]) => ReturnType<SorobanRpc.Server["sendTransaction"]>
getTransaction: (hash: string) => ReturnType<SorobanRpc.Server["getTransaction"]>
Expand Down
2 changes: 1 addition & 1 deletion comebackhere-backend/src/routes/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface CreateInvoiceBody {

// Soroban interaction extracted so it can be replaced in tests
export type SorobanClient = {
getAccount: (publicKey: string) => Promise<Parameters<TransactionBuilder["constructor"]>[0]>
getAccount: (publicKey: string) => Promise<ConstructorParameters<typeof TransactionBuilder>[0]>
simulateTransaction: (tx: Parameters<SorobanRpc.Server["simulateTransaction"]>[0]) => ReturnType<SorobanRpc.Server["simulateTransaction"]>
sendTransaction: (tx: Parameters<SorobanRpc.Server["sendTransaction"]>[0]) => ReturnType<SorobanRpc.Server["sendTransaction"]>
getTransaction: (hash: string) => ReturnType<SorobanRpc.Server["getTransaction"]>
Expand Down
36 changes: 5 additions & 31 deletions comebackhere-backend/src/routes/treasury.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,13 @@ import {
executeSettlementSchema,
escalateHoldSchema,
} from "../schemas/index.js"
import { getBalanceCache, setBalanceCache, invalidateBalanceCache } from "../lib/cache.js"

const router = Router()

// ---------------------------------------------------------------------------
// #212 — In-memory balance cache with TTL
// ---------------------------------------------------------------------------

const BALANCE_CACHE_TTL_MS = 5_000 // 5 second TTL

interface BalanceCacheEntry {
data: Array<{ token: string; balance: string }>
expiresAt: number
}

let _balanceCache: BalanceCacheEntry | null = null

/** Returns cached balances if still fresh, otherwise null. */
export function getBalanceCache(): Array<{ token: string; balance: string }> | null {
if (_balanceCache && Date.now() < _balanceCache.expiresAt) {
return _balanceCache.data
}
return null
}

/** Stores balance data in the cache with a fresh TTL. */
export function setBalanceCache(data: Array<{ token: string; balance: string }>): void {
_balanceCache = { data, expiresAt: Date.now() + BALANCE_CACHE_TTL_MS }
}

/** Immediately invalidates the balance cache (call after execute-settlement / withdrawal). */
export function invalidateBalanceCache(): void {
_balanceCache = null
}
// #212 — balance cache lives in lib/cache.ts so the treasury indexer can
// invalidate it too; re-exported here for existing callers.
export { getBalanceCache, setBalanceCache, invalidateBalanceCache }

/**
* @openapi
Expand Down Expand Up @@ -366,7 +340,7 @@ router.post("/execute-settlement", validateBody(executeSettlementSchema), async
env,
)
// #212 — balance changed; evict the cache so the next GET /balances is fresh
invalidateBalanceCache()
invalidateBalanceCache(`execute-settlement id=${settlementId}`)
res.json(result)
} catch (err: unknown) {
const status = (err as { status?: number })?.status ?? 500
Expand Down
2 changes: 1 addition & 1 deletion comebackhere-backend/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const voteBodySchema = z.object({
.min(1, "signer_address is required")
.refine(isValidStellarAddress, "signer_address must be a valid Stellar public key"),
vote: z.enum(["ResolvedClaimant", "ResolvedCounterparty"], {
errorMap: () => ({ message: "vote must be 'ResolvedClaimant' or 'ResolvedCounterparty'" }),
message: "vote must be 'ResolvedClaimant' or 'ResolvedCounterparty'",
}),
weight: z
.number({ message: "weight must be a positive integer" })
Expand Down
5 changes: 5 additions & 0 deletions comebackhere-backend/src/services/treasury-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type SettlementRecord,
} from "../db/mongo.js"
import { dispatchWebhook } from "./webhooks.js"
import { invalidateBalanceCache } from "../lib/cache.js"

const CURSOR_ID = "treasury_settlement_events"
const POLL_INTERVAL_MS = 5_000
Expand Down Expand Up @@ -279,6 +280,10 @@ export async function processIndexerBatch(
} else if (eventType === "settlement_executed") {
await processSettlementExecuted(settlements, settlementId, txHash)

// Treasury balances changed on-chain; drop the cached copy so the next
// GET /api/treasury/balances reads fresh data instead of waiting for TTL.
invalidateBalanceCache(`settlement_executed id=${settlementId} tx=${txHash}`)

// Dispatch signed webhook for settlement_executed
const webhookUrl = process.env.WEBHOOK_URL
if (webhookUrl) {
Expand Down
Loading
Loading