diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index a126bfaa..f9a5153c 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -13,6 +13,7 @@ import { logInfo, logWarn, } from "@/lib/observability/logger"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { recordDecisionOutcome } from "@/lib/observability/metrics"; import { redactSensitiveFields } from "@/lib/observability/redact"; import { demoScenarios } from "@/lib/scenarios/seed"; @@ -87,17 +88,17 @@ export async function POST(request: NextRequest) { } const userId = auth.session.userId; - const assignedWallet = await getUserWallet(userId); - if (!assignedWallet || "expired" in assignedWallet) { - return jsonWithRequestContext(request, { - route: "/api/decision", - startedAtMs, - status: 401, - body: { error: "No active wallet mapping found for this user." }, - headers: rateLimitHeaders(rate), - }); +const notReady = await readinessBlockResponse( + request, + "/api/decision", + startedAtMs, + rateLimitHeaders(rate), + ); + if (notReady) { + logWarn("Decision route blocked: not ready", { ...context, userId }); + return notReady; } - + const rawBody = (await request.json().catch(() => ({}))) as unknown; const parsedBody = decisionRequestSchema.safeParse(rawBody); if (!parsedBody.success) { diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 0d3667b1..8d7cb023 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -2,6 +2,8 @@ import { NextRequest } from "next/server"; import { jsonWithRequestContext } from "@/lib/observability/http"; import { getRequestLogContext, logInfo } from "@/lib/observability/logger"; +import { getReadiness } from "@/lib/readiness/checks"; +import { readinessBody } from "@/lib/readiness/gate"; import { getBlocklistHealth } from "@/lib/security/blocklist"; import { getHorizonServer } from "@/lib/stellar/client"; import { getDatabaseMigrationStatus } from "@/lib/storage/db"; @@ -12,6 +14,10 @@ export async function GET(request: NextRequest) { logInfo("Health check requested", context); + // Run readiness BEFORE any call to runWithDatabase(), which applies pending + // migrations and would hide a stale schema. + const readiness = await getReadiness(); + const env = { hasGroqKey: Boolean(process.env.GROQ_API_KEY), hasAuthSecret: Boolean(process.env.FORTEXA_AUTH_SECRET), @@ -54,9 +60,9 @@ export async function GET(request: NextRequest) { return jsonWithRequestContext(request, { route: "/api/health", startedAtMs, - status: ready ? 200 : 503, + status: readiness.ready ? 200 : 503, body: { - ok: ready, + ...readinessBody(readiness), service: "fortexa", timestamp: new Date().toISOString(), env, diff --git a/src/app/api/stellar/build-payment/route.ts b/src/app/api/stellar/build-payment/route.ts index fb1321e3..30cfbac2 100644 --- a/src/app/api/stellar/build-payment/route.ts +++ b/src/app/api/stellar/build-payment/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/auth/require-auth"; import { readJsonBody } from "@/lib/http/read-json-body"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { securityHeadersForRequest } from "@/lib/security/headers"; import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client"; @@ -58,7 +59,15 @@ export async function POST(request: NextRequest) { { status: 503, headers: { ...rateLimitHeaders(rate), ...securityHeadersForRequest(request) } } ); } - + const notReady = await readinessBlockResponse( + request, + "/api/stellar/build-payment", + Date.now(), + rateLimitHeaders(rate), + ); + if (notReady) { + return notReady; + } const userId = auth.session.userId; const assignedWallet = await getUserWallet(userId); diff --git a/src/app/api/stellar/pay/route.ts b/src/app/api/stellar/pay/route.ts index 46c734a2..059a2a1b 100644 --- a/src/app/api/stellar/pay/route.ts +++ b/src/app/api/stellar/pay/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/auth/require-auth"; -import { isLoginAuthorizationPayload } from "@/lib/auth/wallet-challenge"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { securityHeadersForRequest } from "@/lib/security/headers"; import { stellarBuildPaymentRequestSchema } from "@/lib/validation/schemas"; @@ -29,7 +29,15 @@ export async function POST(request: NextRequest) { } const userId = auth.session.userId; - + const notReady = await readinessBlockResponse( + request, + "/api/stellar/pay", + Date.now(), + rateLimitHeaders(rate), + ); + if (notReady) { + return notReady; + } const rawPayload = (await request.json().catch(() => ({}))) as unknown; if (isLoginAuthorizationPayload(rawPayload)) { return NextResponse.json( diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 581f82cb..de069d60 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -8,6 +8,8 @@ import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observab import { recordStellarSubmitResult } from "@/lib/observability/metrics"; import { redactSensitiveFields } from "@/lib/observability/redact"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client"; import { assertStellarNetworkConfig } from "@/lib/stellar/network-config"; @@ -211,7 +213,28 @@ export async function POST(request: NextRequest) { headers: rateLimitHeaders(rate), }); } - + + const notReady = await readinessBlockResponse( + request, + "/api/stellar/build-payment", + Date.now(), + rateLimitHeaders(rate), + ); + if (notReady) { + return notReady; + } + + const notReady = await readinessBlockResponse( + request, + "/api/stellar/submit-signed", + startedAtMs, + rateLimitHeaders(rate), + ); + if (notReady) { + logWarn("Submit signed blocked: not ready", context); + return notReady; + } + const userId = auth.session.userId; if (!bodyResult.ok) { diff --git a/src/lib/readiness/checks.ts b/src/lib/readiness/checks.ts new file mode 100644 index 00000000..cacae779 --- /dev/null +++ b/src/lib/readiness/checks.ts @@ -0,0 +1,133 @@ +import { promises as fs } from "node:fs"; + +import { + checkReadiness, + type ReadinessDeps, + type ReadinessResult, +} from "@/lib/readiness/gate"; +import { runWithDatabaseNoMigrate } from "@/lib/storage/db"; +import { STORAGE_MIGRATIONS } from "@/lib/storage/migrations"; +import { getFortexaStorePath } from "@/lib/storage/paths"; +import { + STELLAR_PUBLIC_NETWORK_PASSPHRASE, + STELLAR_TESTNET_NETWORK_PASSPHRASE, +} from "@/lib/stellar/network"; + +function clean(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function usesDatabase(env: NodeJS.ProcessEnv) { + return clean(env.DATABASE_URL) !== null; +} + +/** Latest migration id this build expects. */ +export function getExpectedMigrationId(): string { + const last = STORAGE_MIGRATIONS[STORAGE_MIGRATIONS.length - 1]; + return last ? last.id : "none"; +} + +/** + * Read-only migration check. It must NOT go through runWithDatabase(), which + * applies pending migrations and would hide a stale schema. + * + * Returns the expected id only when every known migration is applied. + * File-store mode has no schema, so there is nothing to migrate. + */ +export async function getAppliedMigrationId( + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (!usesDatabase(env)) { + return getExpectedMigrationId(); + } + + const db = await runWithDatabaseNoMigrate(async (pool) => { + const result = await pool.query<{ id: string }>( + "SELECT id FROM fortexa_schema_migrations", + ); + return result.rows.map((row) => row.id); + }); + + if (!db.available) { + return null; + } + + const applied = new Set(db.value); + return STORAGE_MIGRATIONS.every((migration) => applied.has(migration.id)) + ? getExpectedMigrationId() + : null; +} + +/** + * Read-only policy store check. + * + * getPolicyConfig() silently bootstraps default policy rows when storage is + * empty, so it can never tell us the store is missing. This reads the + * persisted state without creating anything. + */ +export async function loadPolicyStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (usesDatabase(env)) { + const db = await runWithDatabaseNoMigrate(async (pool) => { + const result = await pool.query<{ version: number }>( + "SELECT version FROM fortexa_policy_state WHERE id = 1", + ); + return result.rows.length > 0; + }); + + // A configured but unreachable database must not pass via the file fallback. + return db.available ? db.value === true : false; + } + + try { + const raw = await fs.readFile(getFortexaStorePath("policy.json"), "utf8"); + const parsed = JSON.parse(raw) as { policy?: unknown; version?: unknown }; + return Boolean(parsed.policy) && typeof parsed.version === "number"; + } catch { + return false; + } +} + +export function getConfiguredPassphrase( + env: NodeJS.ProcessEnv = process.env, +): string | null { + return clean(env.STELLAR_NETWORK_PASSPHRASE); +} + +/** + * The passphrase this deployment is supposed to use. + * FORTEXA_EXPECTED_NETWORK_PASSPHRASE wins when set; otherwise production + * expects the public network and everything else expects testnet. + */ +export function getExpectedPassphrase( + env: NodeJS.ProcessEnv = process.env, +): string { + return ( + clean(env.FORTEXA_EXPECTED_NETWORK_PASSPHRASE) ?? + (env.NODE_ENV === "production" + ? STELLAR_PUBLIC_NETWORK_PASSPHRASE + : STELLAR_TESTNET_NETWORK_PASSPHRASE) + ); +} + +export function defaultReadinessDeps( + env: NodeJS.ProcessEnv = process.env, +): ReadinessDeps { + return { + getAppliedMigrationId: () => getAppliedMigrationId(env), + expectedMigrationId: getExpectedMigrationId(), + loadPolicyStore: () => loadPolicyStore(env), + getConfiguredPassphrase: () => getConfiguredPassphrase(env), + expectedPassphrase: getExpectedPassphrase(env), + }; +} + +/** One readiness result for health and for the pay/decision routes. */ +export function getReadiness( + env: NodeJS.ProcessEnv = process.env, + overrides: Partial = {}, +): Promise { + return checkReadiness({ ...defaultReadinessDeps(env), ...overrides }); +} diff --git a/src/lib/readiness/gate.test.ts b/src/lib/readiness/gate.test.ts new file mode 100644 index 00000000..4ee2ec5c --- /dev/null +++ b/src/lib/readiness/gate.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { checkReadiness, readinessBody, type ReadinessDeps } from "./gate"; + +const PUBLIC = "Public Global Stellar Network ; September 2015"; +const TESTNET = "Test SDF Network ; September 2015"; + +function deps(overrides: Partial = {}): ReadinessDeps { + return { + getAppliedMigrationId: async () => "0003_latest", + expectedMigrationId: "0003_latest", + loadPolicyStore: async () => true, + getConfiguredPassphrase: () => PUBLIC, + expectedPassphrase: PUBLIC, + ...overrides, + }; +} + +describe("checkReadiness", () => { + it("is ready when migration, policy store and network all pass", async () => { + const result = await checkReadiness(deps()); + expect(result.ready).toBe(true); + expect(result.failing).toEqual([]); + expect(result.checks).toEqual({ + migration: "pass", + policy_store: "pass", + network: "pass", + }); + }); + + it("is not ready and names the migration check for a stale migration", async () => { + const result = await checkReadiness( + deps({ getAppliedMigrationId: async () => "0002_older" }), + ); + expect(result.ready).toBe(false); + expect(result.failing).toEqual(["migration"]); + }); + + it("fails the migration check when no migration is applied", async () => { + const result = await checkReadiness( + deps({ getAppliedMigrationId: async () => null }), + ); + expect(result.failing).toEqual(["migration"]); + }); + + it("fails the migration check when reading it throws", async () => { + const result = await checkReadiness( + deps({ + getAppliedMigrationId: async () => { + throw new Error("db down"); + }, + }), + ); + expect(result.ready).toBe(false); + expect(result.failing).toEqual(["migration"]); + }); + + it("is not ready when the policy store is empty or unavailable", async () => { + const empty = await checkReadiness(deps({ loadPolicyStore: async () => false })); + expect(empty.failing).toEqual(["policy_store"]); + + const broken = await checkReadiness( + deps({ + loadPolicyStore: async () => { + throw new Error("cannot read"); + }, + }), + ); + expect(broken.failing).toEqual(["policy_store"]); + }); + + it("is not ready on a passphrase mismatch", async () => { + const result = await checkReadiness( + deps({ getConfiguredPassphrase: () => TESTNET }), + ); + expect(result.ready).toBe(false); + expect(result.failing).toEqual(["network"]); + }); + + it("is not ready when the passphrase is missing", async () => { + const result = await checkReadiness( + deps({ getConfiguredPassphrase: () => null }), + ); + expect(result.failing).toEqual(["network"]); + }); + + it("reports every failing check", async () => { + const result = await checkReadiness( + deps({ + getAppliedMigrationId: async () => null, + loadPolicyStore: async () => false, + getConfiguredPassphrase: () => TESTNET, + }), + ); + expect(result.failing).toEqual(["migration", "policy_store", "network"]); + }); +}); + +describe("readinessBody", () => { + it("contains only check names and pass/fail, never passphrases or ids", async () => { + const result = await checkReadiness( + deps({ getConfiguredPassphrase: () => TESTNET }), + ); + const serialized = JSON.stringify(readinessBody(result)); + + expect(Object.keys(readinessBody(result)).sort()).toEqual( + ["checks", "failing", "ok", "ready"].sort(), + ); + expect(serialized).not.toContain(TESTNET); + expect(serialized).not.toContain(PUBLIC); + expect(serialized).not.toContain("0003_latest"); + }); +}); diff --git a/src/lib/readiness/gate.ts b/src/lib/readiness/gate.ts new file mode 100644 index 00000000..24a34e3f --- /dev/null +++ b/src/lib/readiness/gate.ts @@ -0,0 +1,82 @@ +/** + * Unified readiness gate. + * + * "Ready" only when ALL of these agree: + * - migration: the applied database migration id is the expected one + * - policy_store: the policy store loads (and is not empty) + * - network: the configured Stellar passphrase is the one this + * deployment expects + * + * Every dependency is injected so tests can stub each one. The result only + * contains check names and pass/fail, never secrets or account ids. + */ + +export type ReadinessCheckName = "migration" | "policy_store" | "network"; + +export type ReadinessResult = { + ready: boolean; + /** Names of the checks that failed (empty when ready). */ + failing: ReadinessCheckName[]; + checks: Record; +}; + +export type ReadinessDeps = { + /** Migration id currently applied to the database (null if none). */ + getAppliedMigrationId: () => Promise; + /** Migration id this build expects. */ + expectedMigrationId: string; + /** Resolves true when the policy store loaded and has policies. */ + loadPolicyStore: () => Promise; + /** Passphrase the running deployment is configured with. */ + getConfiguredPassphrase: () => string | null; + /** Passphrase this deployment is supposed to use. */ + expectedPassphrase: string | null; +}; + +async function safely(check: () => Promise | boolean): Promise { + try { + return await check(); + } catch { + return false; + } +} + +export async function checkReadiness(deps: ReadinessDeps): Promise { + const [migration, policyStore, network] = await Promise.all([ + safely(async () => { + const applied = await deps.getAppliedMigrationId(); + return applied !== null && applied === deps.expectedMigrationId; + }), + safely(() => deps.loadPolicyStore()), + safely(() => { + const configured = deps.getConfiguredPassphrase(); + return ( + configured !== null && + deps.expectedPassphrase !== null && + configured === deps.expectedPassphrase + ); + }), + ]); + + const checks: ReadinessResult["checks"] = { + migration: migration ? "pass" : "fail", + policy_store: policyStore ? "pass" : "fail", + network: network ? "pass" : "fail", + }; + + const failing = (Object.keys(checks) as ReadinessCheckName[]).filter( + (name) => checks[name] === "fail", + ); + + return { ready: failing.length === 0, failing, checks }; +} + +/** Safe JSON body for health and for blocked pay/decision requests. */ +export function readinessBody(result: ReadinessResult) { + return { + ok: result.ready, + ready: result.ready, + failing: result.failing, + checks: result.checks, + }; +} diff --git a/src/lib/readiness/guard.test.ts b/src/lib/readiness/guard.test.ts new file mode 100644 index 00000000..dfa02e17 --- /dev/null +++ b/src/lib/readiness/guard.test.ts @@ -0,0 +1,91 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getReadiness = vi.fn(); + +vi.mock("@/lib/readiness/checks", () => ({ + getReadiness: (...args: unknown[]) => getReadiness(...args), +})); + +import { readinessBlockResponse } from "./guard"; + +const request = () => + new NextRequest("http://localhost/api/decision", { method: "POST" }); + +const readyResult = { + ready: true, + failing: [], + checks: { migration: "pass", policy_store: "pass", network: "pass" }, +}; + +describe("readinessBlockResponse", () => { + beforeEach(() => { + getReadiness.mockReset(); + }); + + it("does nothing outside production", async () => { + const res = await readinessBlockResponse( + request(), + "/api/decision", + Date.now(), + undefined, + { NODE_ENV: "development" } as NodeJS.ProcessEnv, + ); + expect(res).toBeNull(); + expect(getReadiness).not.toHaveBeenCalled(); + }); + + it("lets the request through in production when ready", async () => { + getReadiness.mockResolvedValue(readyResult); + const res = await readinessBlockResponse( + request(), + "/api/decision", + Date.now(), + undefined, + { NODE_ENV: "production" } as NodeJS.ProcessEnv, + ); + expect(res).toBeNull(); + }); + + it("returns 503 naming the failing check when the passphrase mismatches", async () => { + getReadiness.mockResolvedValue({ + ready: false, + failing: ["network"], + checks: { migration: "pass", policy_store: "pass", network: "fail" }, + }); + + const res = await readinessBlockResponse( + request(), + "/api/decision", + Date.now(), + undefined, + { NODE_ENV: "production" } as NodeJS.ProcessEnv, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(503); + const body = await res!.json(); + expect(body.ready).toBe(false); + expect(body.failing).toEqual(["network"]); + }); + + it("names the migration check for a stale migration", async () => { + getReadiness.mockResolvedValue({ + ready: false, + failing: ["migration"], + checks: { migration: "fail", policy_store: "pass", network: "pass" }, + }); + + const res = await readinessBlockResponse( + request(), + "/api/decision", + Date.now(), + undefined, + { NODE_ENV: "production" } as NodeJS.ProcessEnv, + ); + + const body = await res!.json(); + expect(res!.status).toBe(503); + expect(body.failing).toEqual(["migration"]); + }); +}); diff --git a/src/lib/readiness/guard.ts b/src/lib/readiness/guard.ts new file mode 100644 index 00000000..c72a84d6 --- /dev/null +++ b/src/lib/readiness/guard.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; + +import { jsonWithRequestContext } from "@/lib/observability/http"; +import { getReadiness } from "@/lib/readiness/checks"; +import { readinessBody } from "@/lib/readiness/gate"; +import { shouldEnforceProductionReadiness } from "@/lib/readiness/production"; + +type JsonArgs = Parameters[1]; + +/** + * Same readiness gate the health route uses, for routes that cause side + * effects (decision, pay). Returns a 503 response naming the failing checks + * when the deployment is not ready, or null when the request may proceed. + * + * Enforced in production, matching the existing production readiness + * behaviour, so local development and demos keep working. + * Call it BEFORE anything that writes state or moves funds. + */ +export async function readinessBlockResponse( + request: NextRequest, + route: string, + startedAtMs: number, + headers?: JsonArgs["headers"], + env: NodeJS.ProcessEnv = process.env, +) { + if (!shouldEnforceProductionReadiness(env)) { + return null; + } + + const readiness = await getReadiness(env); + if (readiness.ready) { + return null; + } + + return jsonWithRequestContext(request, { + route, + startedAtMs, + status: 503, + body: readinessBody(readiness), + headers, + }); +} diff --git a/src/lib/storage/db.ts b/src/lib/storage/db.ts index 9adf2a2b..39caccc9 100644 --- a/src/lib/storage/db.ts +++ b/src/lib/storage/db.ts @@ -147,6 +147,21 @@ export async function runWithDatabase( return { available: false }; } } +export async function runWithDatabaseNoMigrate( + action: (targetPool: Pool) => Promise +): Promise> { + const targetPool = getPool(); + if (!targetPool) { + return { available: false }; + } + + try { + const value = await action(targetPool); + return { available: true, value }; + } catch { + return { available: false }; + } +} export async function runWithDatabaseStrict( operationName: string,