diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 55b2630..28e4b64 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -146,16 +146,11 @@ export async function connectMongo(): Promise { await invoices.createIndex({ status: 1 }) await invoices.createIndex({ merchant_address: 1 }) await invoices.createIndex({ status: 1, merchant_address: 1 }) + await invoices.createIndex({ created_at: -1 }) const cursors = db.collection("indexer_cursors") await cursors.createIndex({ _id: 1 }, { unique: true }) - const invoices = db.collection("invoices") - await invoices.createIndex({ invoice_id: 1 }, { unique: true }) - await invoices.createIndex({ status: 1 }) - await invoices.createIndex({ merchant_address: 1 }) - await invoices.createIndex({ created_at: -1 }) - return db } @@ -171,10 +166,6 @@ export function getCursorsCollection(database: Db): Collection { return database.collection("indexer_cursors") } -export function getInvoicesCollection(database: Db): Collection { - return database.collection("invoices") -} - export async function closeMongo(): Promise { if (client) { await client.close() diff --git a/comebackhere-backend/src/lib/env.ts b/comebackhere-backend/src/lib/env.ts new file mode 100644 index 0000000..3d4ca17 --- /dev/null +++ b/comebackhere-backend/src/lib/env.ts @@ -0,0 +1,50 @@ +import type { Response } from "express" +import { getNetworkPassphrase } from "./soroban.js" + +/** + * Env object returned by {@link requireEnv} for a route. + * + * `rpcUrl` and `networkPassphrase` are always present. Every additional + * property comes from the `vars` mapping passed to {@link requireEnv}. + */ +export type ContractEnv

> = { + rpcUrl: string + networkPassphrase: string +} & { [Prop in keyof P]: string } + +const MISSING_ENV_ERROR = "Service misconfiguration: missing required environment variables" + +/** + * Reads and validates the env vars a route needs from `process.env`. + * + * `SOROBAN_RPC_URL` (returned as `rpcUrl`) and the network passphrase + * (returned as `networkPassphrase`) are always validated. `vars` maps each + * additional property name to the env var it should be read from, e.g. + * `{ treasuryContractId: "TREASURY_CONTRACT_ID" }`. + * + * If any referenced var is unset, writes a 503 with the standard + * misconfiguration error to `res` and returns null. + */ +export function requireEnv

>( + res: Response, + vars: P, +): ContractEnv

| null { + const missing = [ + !process.env.SOROBAN_RPC_URL ? "SOROBAN_RPC_URL" : null, + ...Object.values(vars).filter((envName) => !process.env[envName]), + ].filter(Boolean) + if (missing.length > 0) { + res.status(503).json({ error: MISSING_ENV_ERROR }) + return null + } + + const values = Object.fromEntries( + Object.entries(vars).map(([prop, envName]) => [prop, process.env[envName] as string]), + ) as { [Prop in keyof P]: string } + + return { + rpcUrl: process.env.SOROBAN_RPC_URL as string, + networkPassphrase: getNetworkPassphrase(), + ...values, + } +} diff --git a/comebackhere-backend/src/routes/compliance.ts b/comebackhere-backend/src/routes/compliance.ts index 500b36e..81e82a6 100644 --- a/comebackhere-backend/src/routes/compliance.ts +++ b/comebackhere-backend/src/routes/compliance.ts @@ -1,13 +1,13 @@ import { Router, type Request, type Response } from "express" import { Keypair, - Networks, TransactionBuilder, BASE_FEE, Contract, nativeToScVal, SorobanRpc, } from "stellar-sdk" +import { requireEnv } from "../lib/env.js" import { validateBody } from "../middleware/validate.js" import { allowBodySchema, blockBodySchema } from "../schemas/index.js" @@ -34,15 +34,6 @@ function buildSorobanClient(rpcUrl: string): SorobanClient { } } -function envOrError(): { rpcUrl: string; contractId: string; signerSecret: string; networkPassphrase: string } | null { - const rpcUrl = process.env.SOROBAN_RPC_URL - const contractId = process.env.COMPLIANCE_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? Networks.STANDALONE - if (!rpcUrl || !contractId || !signerSecret) return null - return { rpcUrl, contractId, signerSecret, networkPassphrase } -} - // --------------------------------------------------------------------------- // Core call — submit a compliance operation and return updated status // --------------------------------------------------------------------------- @@ -138,11 +129,11 @@ router.post("/allow", validateBody(allowBodySchema), async (req: Request, res: R const { address, until } = req.body as { address: string; until?: number } - const env = envOrError() - if (!env) { - res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) - return - } + const env = requireEnv(res, { + complianceContractId: "COMPLIANCE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) + if (!env) return try { const client = buildSorobanClient(env.rpcUrl) @@ -155,7 +146,7 @@ router.post("/allow", validateBody(allowBodySchema), async (req: Request, res: R operation as "allow_address" | "allow_address_until", args, client, - env.contractId, + env.complianceContractId, env.signerSecret, env.networkPassphrase ) @@ -190,11 +181,11 @@ router.post("/block", validateBody(blockBodySchema), async (req: Request, res: R const { address } = req.body as { address: string } - const env = envOrError() - if (!env) { - res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) - return - } + const env = requireEnv(res, { + complianceContractId: "COMPLIANCE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) + if (!env) return // Audit log — admin identity + timestamp console.log(`[compliance] block_address admin="${adminKey}" address="${address}" ts="${new Date().toISOString()}"`) @@ -205,7 +196,7 @@ router.post("/block", validateBody(blockBodySchema), async (req: Request, res: R "block_address", [nativeToScVal(address, { type: "address" })], client, - env.contractId, + env.complianceContractId, env.signerSecret, env.networkPassphrase ) diff --git a/comebackhere-backend/src/routes/disputes.ts b/comebackhere-backend/src/routes/disputes.ts index 73960e6..f992444 100644 --- a/comebackhere-backend/src/routes/disputes.ts +++ b/comebackhere-backend/src/routes/disputes.ts @@ -1,4 +1,5 @@ import { Router, type Request, type Response } from "express" +import { requireEnv } from "../lib/env.js" import { validateBody } from "../middleware/validate.js" import { voteBodySchema, createDisputeSchema } from "../schemas/index.js" @@ -208,14 +209,7 @@ export interface CreateDisputeBody { router.post("/", validateBody(createDisputeSchema), async (req: Request, res: Response) => { const body = req.body as CreateDisputeBody - const rpcUrl = process.env.SOROBAN_RPC_URL - const settlementContractId = process.env.SETTLEMENT_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - - if (!rpcUrl || !settlementContractId || !signerSecret) { - res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) - return - } + if (!requireEnv(res, { settlementContractId: "SETTLEMENT_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY" })) return const settlementId = body.settlement_id const claimantAddress = body.claimant_address diff --git a/comebackhere-backend/src/routes/invoice-settings.ts b/comebackhere-backend/src/routes/invoice-settings.ts index 4df8bc9..ba5bb31 100644 --- a/comebackhere-backend/src/routes/invoice-settings.ts +++ b/comebackhere-backend/src/routes/invoice-settings.ts @@ -2,37 +2,16 @@ import { Router, type Request, type Response } from "express" import { Keypair, nativeToScVal } from "stellar-sdk" import { buildSorobanClient, - getNetworkPassphrase, simulateContractRead, submitContractCall, type SorobanClient, } from "../lib/soroban.js" +import { requireEnv } from "../lib/env.js" import { validateBody } from "../middleware/validate.js" import { graceWindowSchema } from "../schemas/index.js" const router = Router() -function requireEnv(res: Response): { - rpcUrl: string - invoiceContractId: string - signerSecret: string - networkPassphrase: string -} | null { - const rpcUrl = process.env.SOROBAN_RPC_URL - const invoiceContractId = process.env.INVOICE_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = getNetworkPassphrase() - - if (!rpcUrl || !invoiceContractId || !signerSecret) { - res.status(503).json({ - error: "Service misconfiguration: missing required environment variables", - }) - return null - } - - return { rpcUrl, invoiceContractId, signerSecret, networkPassphrase } -} - /** * @openapi * /api/invoice/grace-window: @@ -58,7 +37,10 @@ function requireEnv(res: Response): { * $ref: '#/components/schemas/ErrorResponse' */ router.get("/grace-window", async (_req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + invoiceContractId: "INVOICE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return try { @@ -161,7 +143,10 @@ export async function setGraceWindow( * $ref: '#/components/schemas/ErrorResponse' */ router.post("/grace-window", validateBody(graceWindowSchema), async (req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + invoiceContractId: "INVOICE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return const graceWindowSeconds = req.body.grace_window_seconds diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index 43cef4e..99c1c2b 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -1,7 +1,10 @@ import { Router, type Request, type Response } from "express" -import { Keypair, Networks, TransactionBuilder, BASE_FEE, Contract, nativeToScVal, SorobanRpc, xdr } from "stellar-sdk" +import { Keypair, TransactionBuilder, BASE_FEE, Contract, nativeToScVal, SorobanRpc, xdr } from "stellar-sdk" import { connectMongo, getInvoicesCollection, type InvoiceRecord, type InvoiceStatus } from "../db/mongo.js" +import { requireEnv } from "../lib/env.js" import { cacheGet, cacheSet } from "../lib/cache.js" +import { validateBody, validateParams } from "../middleware/validate.js" +import { createInvoiceSchema, invoiceIdParamSchema } from "../schemas/index.js" const router = Router() @@ -249,18 +252,12 @@ router.get("/", async (req: Request, res: Response) => { router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, res: Response) => { const { id } = req.params - const rpcUrl = process.env.SOROBAN_RPC_URL - const contractId = process.env.INVOICE_CONTRACT_ID - const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? Networks.STANDALONE - - if (!rpcUrl || !contractId) { - res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) - return - } + const env = requireEnv(res, { invoiceContractId: "INVOICE_CONTRACT_ID" }) + if (!env) return try { - const server = new SorobanRpc.Server(rpcUrl) - const contract = new Contract(contractId) + const server = new SorobanRpc.Server(env.rpcUrl) + const contract = new Contract(env.invoiceContractId) // Build a read-only ledger entry query for the invoice const ledgerKey = contract.getFootprint() @@ -270,7 +267,7 @@ router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, re const entries = await server.getLedgerEntries( xdr.LedgerKey.contractData( new xdr.LedgerKeyContractData({ - contract: new Contract(contractId).address().toScAddress(), + contract: new Contract(env.invoiceContractId).address().toScAddress(), key: nativeToScVal(BigInt(id), { type: "u64" }), durability: xdr.ContractDataDurability.persistent(), }) @@ -358,24 +355,20 @@ router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, re * $ref: '#/components/schemas/ErrorResponse' */ router.post("/", validateBody(createInvoiceSchema), async (req: Request, res: Response) => { - const rpcUrl = process.env.SOROBAN_RPC_URL - const contractId = process.env.INVOICE_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? Networks.STANDALONE - - if (!rpcUrl || !contractId || !signerSecret) { - res.status(503).json({ error: "Service misconfiguration: missing required environment variables" }) - return - } + const env = requireEnv(res, { + invoiceContractId: "INVOICE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) + if (!env) return try { - const client = buildSorobanClient(rpcUrl) + const client = buildSorobanClient(env.rpcUrl) const result = await createInvoice( req.body as CreateInvoiceBody, client, - contractId, - signerSecret, - networkPassphrase + env.invoiceContractId, + env.signerSecret, + env.networkPassphrase ) const db = await connectMongo() diff --git a/comebackhere-backend/src/routes/release-escrow.ts b/comebackhere-backend/src/routes/release-escrow.ts index 0e9a028..1b8d37e 100644 --- a/comebackhere-backend/src/routes/release-escrow.ts +++ b/comebackhere-backend/src/routes/release-escrow.ts @@ -2,36 +2,15 @@ import { Router, type Request, type Response } from "express" import { Keypair, nativeToScVal } from "stellar-sdk" import { buildSorobanClient, - getNetworkPassphrase, submitContractCall, type SorobanClient, } from "../lib/soroban.js" +import { requireEnv } from "../lib/env.js" import { validateBody, validateParams } from "../middleware/validate.js" import { releaseEscrowIdParamSchema } from "../schemas/index.js" const router = Router({ mergeParams: true }) -function requireEnv(res: Response): { - rpcUrl: string - invoiceContractId: string - signerSecret: string - networkPassphrase: string -} | null { - const rpcUrl = process.env.SOROBAN_RPC_URL - const invoiceContractId = process.env.INVOICE_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = getNetworkPassphrase() - - if (!rpcUrl || !invoiceContractId || !signerSecret) { - res.status(503).json({ - error: "Service misconfiguration: missing required environment variables", - }) - return null - } - - return { rpcUrl, invoiceContractId, signerSecret, networkPassphrase } -} - export interface ReleaseEscrowResult { invoice_id: number status: "Released" @@ -102,7 +81,10 @@ router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), a const { id } = req.params const invoiceId = parseInt(id, 10) - const env = requireEnv(res) + const env = requireEnv(res, { + invoiceContractId: "INVOICE_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return try { diff --git a/comebackhere-backend/src/routes/threshold.ts b/comebackhere-backend/src/routes/threshold.ts index 901a8da..76c4d74 100644 --- a/comebackhere-backend/src/routes/threshold.ts +++ b/comebackhere-backend/src/routes/threshold.ts @@ -2,43 +2,25 @@ import { Router, type Request, type Response } from "express" import { Keypair, nativeToScVal } from "stellar-sdk" import { buildSorobanClient, - getNetworkPassphrase, simulateContractRead, submitContractCall, type SorobanClient, } from "../lib/soroban.js" +import { requireEnv } from "../lib/env.js" import { validateBody } from "../middleware/validate.js" import { thresholdSchema } from "../schemas/index.js" const router = Router() -function requireEnv(res: Response): { - rpcUrl: string - treasuryContractId: string - signerSecret: string - networkPassphrase: string -} | null { - const rpcUrl = process.env.SOROBAN_RPC_URL - const treasuryContractId = process.env.TREASURY_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = getNetworkPassphrase() - - if (!rpcUrl || !treasuryContractId || !signerSecret) { - res.status(503).json({ - error: "Service misconfiguration: missing required environment variables", - }) - return null - } - - return { rpcUrl, treasuryContractId, signerSecret, networkPassphrase } -} - /** * GET /api/treasury/threshold * Returns the current approval threshold from the treasury contract. */ router.get("/threshold", async (_req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + treasuryContractId: "TREASURY_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return try { @@ -95,7 +77,10 @@ export async function setThreshold( } router.post("/threshold", validateBody(thresholdSchema), async (req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + treasuryContractId: "TREASURY_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return const threshold = req.body.threshold diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index a23e42a..753add9 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -2,12 +2,12 @@ import { Router, type Request, type Response } from "express" import { Keypair, nativeToScVal, Address } from "stellar-sdk" import { buildSorobanClient, - getNetworkPassphrase, getOnChainSettlement, getTokenBalance, submitContractCall, type SorobanClient, } from "../lib/soroban.js" +import { requireEnv } from "../lib/env.js" import { connectMongo, getSettlementsCollection } from "../db/mongo.js" import { validateBody } from "../middleware/validate.js" import { @@ -49,29 +49,6 @@ export function invalidateBalanceCache(): void { _balanceCache = null } -function requireEnv(res: Response): { - rpcUrl: string - treasuryContractId: string - usdcContractId: string - signerSecret: string - networkPassphrase: string -} | null { - const rpcUrl = process.env.SOROBAN_RPC_URL - const treasuryContractId = process.env.TREASURY_CONTRACT_ID - const usdcContractId = process.env.USDC_CONTRACT_ID - const signerSecret = process.env.SIGNER_SECRET_KEY - const networkPassphrase = getNetworkPassphrase() - - if (!rpcUrl || !treasuryContractId || !usdcContractId || !signerSecret) { - res.status(503).json({ - error: "Service misconfiguration: missing required environment variables", - }) - return null - } - - return { rpcUrl, treasuryContractId, usdcContractId, signerSecret, networkPassphrase } -} - /** * @openapi * /api/treasury/pending-settlements: @@ -159,7 +136,11 @@ router.get("/pending-settlements", async (_req: Request, res: Response) => { * $ref: '#/components/schemas/ErrorResponse' */ router.post("/approve-settlement", validateBody(settlementIdSchema), async (req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + treasuryContractId: "TREASURY_CONTRACT_ID", + usdcContractId: "USDC_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return const settlementId = req.body.settlement_id @@ -369,7 +350,11 @@ export async function executeSettlementWithBalanceCheck( * $ref: '#/components/schemas/ErrorResponse' */ router.post("/execute-settlement", validateBody(executeSettlementSchema), async (req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + treasuryContractId: "TREASURY_CONTRACT_ID", + usdcContractId: "USDC_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return const { settlement_id: settlementId, token_contract } = req.body as { settlement_id: number; token_contract?: string } @@ -611,7 +596,11 @@ router.post("/escalate-hold", validateBody(escalateHoldSchema), async (req: Requ * Results are cached for up to 5 seconds to reduce Soroban RPC load (#212). */ router.get("/balances", async (_req: Request, res: Response) => { - const env = requireEnv(res) + const env = requireEnv(res, { + treasuryContractId: "TREASURY_CONTRACT_ID", + usdcContractId: "USDC_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) if (!env) return // #212 — serve from cache when available