From bd035ccef3258917ce77fa7203503e1ff1c3905b Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Tue, 29 Sep 2026 23:43:02 +0100 Subject: [PATCH 01/14] Implement unified readiness gate with checks This file implements a unified readiness gate that checks the status of various dependencies to determine if the system is ready. It includes types for readiness checks, results, and dependencies, along with functions to perform the checks and format the results. --- src/lib/readiness/gate.ts | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/lib/readiness/gate.ts 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, + }; +} From 4e5586764d1ef42f24fac9ab2784ca9917e1a75d Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Tue, 29 Sep 2026 23:49:04 +0100 Subject: [PATCH 02/14] Add readiness checks for policy store and passphrases This file contains functions to check the readiness of the policy store and retrieve configured passphrases based on environment variables. --- src/lib/readiness/checks.ts | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/lib/readiness/checks.ts diff --git a/src/lib/readiness/checks.ts b/src/lib/readiness/checks.ts new file mode 100644 index 00000000..cfd523c5 --- /dev/null +++ b/src/lib/readiness/checks.ts @@ -0,0 +1,71 @@ +import { promises as fs } from "node:fs"; + +import { runWithDatabase } from "@/lib/storage/db"; +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; +} + +/** + * 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 check reads the + * persisted state without creating anything. + */ +export async function loadPolicyStore( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const db = await runWithDatabase("readiness-policy-store", async (pool) => { + const result = await pool.query<{ version: number }>( + "SELECT version FROM fortexa_policy_state WHERE id = 1", + ); + return result.rows.length > 0; + }); + + if (db.available) { + return db.value === true; + } + + // A configured database that is unreachable must not silently pass via the + // file fallback. + if (clean(env.DATABASE_URL)) { + return 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) + ); +} From 3ad496d28d6d4deda4d6cbf76e86ba16e2165075 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Tue, 29 Sep 2026 23:58:35 +0100 Subject: [PATCH 03/14] Refactor readiness checks and database handling --- src/lib/readiness/checks.ts | 94 ++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/src/lib/readiness/checks.ts b/src/lib/readiness/checks.ts index cfd523c5..cacae779 100644 --- a/src/lib/readiness/checks.ts +++ b/src/lib/readiness/checks.ts @@ -1,6 +1,12 @@ import { promises as fs } from "node:fs"; -import { runWithDatabase } from "@/lib/storage/db"; +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, @@ -12,31 +18,67 @@ function clean(value: string | undefined): string | null { 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 check reads the + * 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 { - const db = await runWithDatabase("readiness-policy-store", async (pool) => { - const result = await pool.query<{ version: number }>( - "SELECT version FROM fortexa_policy_state WHERE id = 1", - ); - return result.rows.length > 0; - }); - - if (db.available) { - return db.value === true; - } + 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 database that is unreachable must not silently pass via the - // file fallback. - if (clean(env.DATABASE_URL)) { - return false; + // A configured but unreachable database must not pass via the file fallback. + return db.available ? db.value === true : false; } try { @@ -69,3 +111,23 @@ export function getExpectedPassphrase( : 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 }); +} From 971e85d6377bc597ba59f4a37934b428f66379ab Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 00:02:19 +0100 Subject: [PATCH 04/14] Fix missing newline at end of gate.ts Add missing newline at the end of the file From 9a355ffa68e089b971e5c4341b58fc236ced9d58 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 00:08:23 +0100 Subject: [PATCH 05/14] Add runWithDatabaseNoMigrate function --- src/lib/storage/db.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lib/storage/db.ts b/src/lib/storage/db.ts index b6745fdc..ce7c9aa5 100644 --- a/src/lib/storage/db.ts +++ b/src/lib/storage/db.ts @@ -104,6 +104,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 __resetDatabaseForTests() { initPromise = null; From 71f3e6e46cf49f24471050f0aa3c0c47c532d86c Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 05:49:50 +0100 Subject: [PATCH 06/14] Add tests for readiness checks and readinessBody --- src/lib/readiness/gate.test.ts | 113 +++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/lib/readiness/gate.test.ts 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"); + }); +}); From 9c39f252446a2cc2abcafbc844d824f2a32d6a70 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 06:46:50 +0100 Subject: [PATCH 07/14] Add readinessBlockResponse function for readiness checks Implement readinessBlockResponse to manage readiness checks for routes with side effects. --- src/lib/readiness/guard.ts | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/lib/readiness/guard.ts 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, + }); +} From 7718bf7022d115d94f1fbf3e66af90363bcfc201 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 06:47:56 +0100 Subject: [PATCH 08/14] Add tests for readinessBlockResponse function --- src/lib/readiness/guard.test.ts | 91 +++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/lib/readiness/guard.test.ts 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"]); + }); +}); From 526e4e5d9d73d8570b5888d38268373aed5f2f5e Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 06:54:27 +0100 Subject: [PATCH 09/14] Update route.ts --- src/app/api/decision/route.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index 215e6290..5e0722e1 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -10,6 +10,7 @@ import { logInfo, logWarn, } from "@/lib/observability/logger"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { recordDecisionOutcome } from "@/lib/observability/metrics"; import { demoScenarios } from "@/lib/scenarios/seed"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; @@ -54,7 +55,16 @@ export async function POST(request: NextRequest) { } const userId = auth.session.userId; - +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); From e66648cbc0cf0b865fc76ca327cb076949bb2b4e Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 07:02:59 +0100 Subject: [PATCH 10/14] Update route.ts --- src/app/api/stellar/pay/route.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/stellar/pay/route.ts b/src/app/api/stellar/pay/route.ts index 91040c8c..8a95caad 100644 --- a/src/app/api/stellar/pay/route.ts +++ b/src/app/api/stellar/pay/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuth } from "@/lib/auth/require-auth"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { stellarBuildPaymentRequestSchema } from "@/lib/validation/schemas"; import { logValidationFailure, toPublicValidationDetails } from "@/lib/validation/errors"; @@ -27,7 +28,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; const parsedPayload = stellarBuildPaymentRequestSchema.safeParse(rawPayload); From 91a76583c6cdd42bfa1fab5193f9d1abf9a23d8c Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 07:12:37 +0100 Subject: [PATCH 11/14] Update route.ts --- src/app/api/stellar/submit-signed/route.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 664a2cf8..730ed3e4 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -6,6 +6,7 @@ import { jsonWithRequestContext } from "@/lib/observability/http"; import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observability/logger"; import { recordStellarSubmitResult } from "@/lib/observability/metrics"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; +import { readinessBlockResponse } from "@/lib/readiness/guard"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client"; import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; @@ -152,7 +153,17 @@ 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 userId = auth.session.userId; const bodyResult = await readJsonBody(request); From 0e3ad6868975bd7e99c35309086410c81a1eca13 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 07:18:23 +0100 Subject: [PATCH 12/14] Update route.ts --- src/app/api/stellar/submit-signed/route.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 730ed3e4..1b7960aa 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -7,6 +7,7 @@ import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observab import { recordStellarSubmitResult } from "@/lib/observability/metrics"; 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 { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; @@ -163,6 +164,17 @@ export async function POST(request: NextRequest) { 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; From 3f0c2a379469538b158f49ade98dbc6d7944cc56 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 07:34:45 +0100 Subject: [PATCH 13/14] Update route.ts --- src/app/api/health/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 72a89f3b..a2d8018c 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 { runWithDatabase } 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), @@ -49,9 +55,9 @@ export async function GET(request: NextRequest) { return jsonWithRequestContext(request, { route: "/api/health", startedAtMs, - status: 200, + status: readiness.ready ? 200 : 503, body: { - ok: true, + ...readinessBody(readiness), service: "fortexa", timestamp: new Date().toISOString(), env, From ccacce833fde205c343d4de6f672673b3133ef84 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Wed, 30 Sep 2026 07:38:23 +0100 Subject: [PATCH 14/14] Update route.ts --- src/app/api/stellar/build-payment/route.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/stellar/build-payment/route.ts b/src/app/api/stellar/build-payment/route.ts index 6a977dc0..b3a1bd1b 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 { buildUnsignedPaymentTransaction } from "@/lib/stellar/client"; import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; @@ -44,7 +45,15 @@ export async function POST(request: NextRequest) { { status: 503, headers: rateLimitHeaders(rate) } ); } - + 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);