Skip to content
Open
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
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
# Development/testnet example values.
# Replace these demo defaults before deploying or enabling protected payment flows in production.

# Stellar testnet endpoints
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
# Optional override; defaults to the testnet passphrase when unset.
# Must match STELLAR_HORIZON_URL (testnet vs public/mainnet).
STELLAR_NETWORK_PASSPHRASE=

# Optional Postgres persistence (Sprint 3)
# Production readiness check requires one explicit persistence backend:
# - DATABASE_URL for Postgres, or
# - FORTEXA_STORE_DIR for a durable file-backed store
DATABASE_URL=
DATABASE_SSL=false

# Optional file-fallback storage directory
# Explicit file-backed storage directory (required in production if DATABASE_URL is unset)
# Local default: .fortexa
# Vercel default: /tmp/fortexa
FORTEXA_STORE_DIR=

# Shared security state is required in production for lockout/rate-limit durability.
# Configure either FORTEXA_SHARED_STATE_PATH or REDIS_URL.
# Optional shared security state for multi-instance lockout/rate-limit
# Local example: .fortexa/shared-security-state.json
# Vercel example: /tmp/fortexa/shared-security-state.json
Expand All @@ -26,9 +33,10 @@ REDIS_URL=
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile

# Fortexa auth (required)
# Fortexa auth (required in production)
FORTEXA_AUTH_SECRET=
# Wallet-only login allowlists (comma-separated Stellar public keys)
# At least one operator wallet should be configured in production to disable demo fallback.
FORTEXA_OPERATOR_WALLETS=
FORTEXA_VIEWER_WALLETS=
FORTEXA_AUTH_MAX_ATTEMPTS=5
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ To clean up local developer state safely, you can use the local demo reset utili
```
*(or `FORTEXA_ALLOW_LOCAL_RESET=true npx tsx scripts/reset-local-demo-state.ts --yes`)*

`.env.example` is intentionally development-oriented and uses Stellar testnet defaults, so it will not pass the production readiness check until you replace the demo values with production configuration.

---

## 9) 🌍 Environment Variables
Expand Down Expand Up @@ -316,6 +318,7 @@ npm run start
npm run lint
npm test
npm run test:watch
npm run check:production-readiness
npm run demo:scenarios
npm run db:migrate
```
Expand All @@ -336,6 +339,48 @@ Run the standalone demo runner (prints expected vs actual for every seeded scena
npm run demo:scenarios
```

### Production Readiness Check

Run this before every production deployment and before enabling protected payment flows:

```bash
npm run check:production-readiness
```

The readiness check validates:

- `STELLAR_HORIZON_URL`
- `STELLAR_NETWORK_PASSPHRASE`
- `FORTEXA_AUTH_SECRET`
- `FORTEXA_OPERATOR_WALLETS`
- `DATABASE_URL` or `FORTEXA_STORE_DIR`
- `REDIS_URL` or `FORTEXA_SHARED_STATE_PATH`

It also rejects unsafe demo/default values such as testnet Horizon endpoints, mismatched Stellar network settings, and local demo file-store paths without printing secret values.

Expected behavior:

- Success: prints `Fortexa production readiness check passed.` and exits `0`.
- Failure: prints `Fortexa production readiness check failed:` followed by the invalid setting and remediation for each issue, then exits non-zero.

Example success:

```bash
$ npm run check:production-readiness
Fortexa production readiness check passed.
```

Example failure:

```bash
$ npm run check:production-readiness
Fortexa production readiness check failed:
- STELLAR_NETWORK_PASSPHRASE: Testnet passphrase is still configured. Set STELLAR_NETWORK_PASSPHRASE to the Stellar public network passphrase before deployment.
- DATABASE_URL or FORTEXA_STORE_DIR: No persistent storage backend is explicitly configured. Configure DATABASE_URL for Postgres or set FORTEXA_STORE_DIR to a durable production storage path.
```

In `NODE_ENV=production`, Fortexa also applies this check before `/api/stellar/build-payment` and `/api/stellar/submit-signed` execute. If configuration is unsafe, those routes return `503` with a non-sensitive issue list and the remediation command instead of attempting the payment flow.

---

## 11) 🔌 API Surface (Reference)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"check:production-readiness": "tsx scripts/check-production-readiness.ts",
"demo:scenarios": "tsx scripts/demo-scenarios.ts",
"db:migrate": "tsx scripts/run-db-migrations.ts",
"demo:reset": "tsx scripts/reset-local-demo-state.ts",
Expand Down
18 changes: 18 additions & 0 deletions scripts/check-production-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { loadEnvConfig } from "@next/env";

import {
checkProductionReadiness,
formatProductionReadinessReport,
} from "../src/lib/readiness/production";

loadEnvConfig(process.cwd());

const report = checkProductionReadiness(process.env);
const output = formatProductionReadinessReport(report);

if (!report.ok) {
console.error(output);
process.exitCode = 1;
} else {
console.log(output);
}
1 change: 1 addition & 0 deletions src/app/api/audit/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ describe("/api/audit/export route", () => {
},
}
);
const response = await GET(request);

expect(response.status).toBe(200);

Expand Down
14 changes: 14 additions & 0 deletions src/app/api/stellar/build-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,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 { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit";
import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client";
import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote";
Expand Down Expand Up @@ -30,6 +31,19 @@ export async function POST(request: NextRequest) {
return auth.response;
}

const readinessReport = getProtectedPaymentFlowReadinessReport();
if (readinessReport) {
return NextResponse.json(
{
error:
"Protected payment flows are disabled until Fortexa passes the production readiness check.",
issues: readinessReport.issues,
command: "npm run check:production-readiness",
},
{ status: 503, headers: rateLimitHeaders(rate) }
);
}

const userId = auth.session.userId;
const assignedWallet = await getUserWallet(userId);

Expand Down
17 changes: 15 additions & 2 deletions src/app/api/stellar/submit-signed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ import { requireAuth } from "@/lib/auth/require-auth";
import { readJsonBody } from "@/lib/http/read-json-body";
import { getUserWallet } from "@/lib/storage/user-wallet-store";
import { stellarSubmitSignedRequestSchema } from "@/lib/validation/schemas";
import { POST } from "./route";

function buildSignedXdr(signerKp: Keypair, sourcePublicKey: string) {
const account = new Account(sourcePublicKey, "1");
Expand Down Expand Up @@ -218,6 +217,13 @@ function viewerCookie() {

describe("POST /api/stellar/submit-signed authorization", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(requireAuth).mockReturnValue({
ok: false,
response: new Response(JSON.stringify({ error: "Authentication required." }), {
status: 401,
}),
} as ReturnType<typeof requireAuth>);

const request = new NextRequest("http://localhost/api/stellar/submit-signed", {
method: "POST",
headers: { "content-type": "application/json" },
Expand All @@ -229,6 +235,13 @@ describe("POST /api/stellar/submit-signed authorization", () => {
});

it("returns 403 for viewer role (operator-only route)", async () => {
vi.mocked(requireAuth).mockReturnValue({
ok: false,
response: new Response(JSON.stringify({ error: "Insufficient role." }), {
status: 403,
}),
} as ReturnType<typeof requireAuth>);

const request = new NextRequest("http://localhost/api/stellar/submit-signed", {
method: "POST",
headers: {
Expand All @@ -241,4 +254,4 @@ describe("POST /api/stellar/submit-signed authorization", () => {
const response = await POST(request);
expect(response.status).toBe(403);
});
});
});
27 changes: 21 additions & 6 deletions src/app/api/stellar/submit-signed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { readJsonBody } from "@/lib/http/read-json-body";
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 { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit";
import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client";
import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network";
import {
getIdempotencyRecord,
hashSignedXdr,
Expand Down Expand Up @@ -48,10 +50,6 @@ const HORIZON_OP_ERRORS: Record<string, HorizonErrorContext> = {
},
};

function getTestnetExplorerUrl(hash: string) {
return `https://stellar.expert/explorer/testnet/tx/${hash}`;
}

export function formatSubmitError(error: unknown) {
if (!(error instanceof Error)) {
return { message: "Failed to submit signed transaction." };
Expand Down Expand Up @@ -134,6 +132,23 @@ export async function POST(request: NextRequest) {
return auth.response;
}

const readinessReport = getProtectedPaymentFlowReadinessReport();
if (readinessReport) {
logWarn("Submit signed blocked by production readiness check", context);
return jsonWithRequestContext(request, {
route: "/api/stellar/submit-signed",
startedAtMs,
status: 503,
body: {
error:
"Protected payment flows are disabled until Fortexa passes the production readiness check.",
issues: readinessReport.issues,
command: "npm run check:production-readiness",
},
headers: rateLimitHeaders(rate),
});
}

const userId = auth.session.userId;

const bodyResult = await readJsonBody(request);
Expand Down Expand Up @@ -287,7 +302,7 @@ export async function POST(request: NextRequest) {
mode: "real",
...submitted,
},
explorerUrl: getTestnetExplorerUrl(submitted.hash),
explorerUrl: getStellarExplorerTransactionUrl(submitted.hash),
};

if (idempotencyKey && xdrHash) {
Expand Down Expand Up @@ -329,4 +344,4 @@ export async function POST(request: NextRequest) {
headers: rateLimitHeaders(rate),
});
}
}
}
Loading