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
21 changes: 11 additions & 10 deletions src/app/api/decision/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/app/api/stellar/build-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
12 changes: 10 additions & 2 deletions src/app/api/stellar/pay/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 24 additions & 1 deletion src/app/api/stellar/submit-signed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
133 changes: 133 additions & 0 deletions src/lib/readiness/checks.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<boolean> {
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<ReadinessDeps> = {},
): Promise<ReadinessResult> {
return checkReadiness({ ...defaultReadinessDeps(env), ...overrides });
}
Loading