From d5180999ef179da0c3b33291facb37cc2c7eb1d8 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 30 Sep 2026 10:08:58 +0100 Subject: [PATCH 1/5] fix: reject submit-signed from different session wallet --- package-lock.json | 8 ++-- package.json | 2 +- patch.js | 34 +++++++++++++++++ patch2.js | 11 ++++++ patch3.js | 16 ++++++++ patch_idem.js | 9 +++++ patch_refresh.js | 19 ++++++++++ src/app/api/auth/login/route.ts | 1 + src/app/api/auth/refresh/route.test.ts | 3 ++ src/app/api/auth/refresh/route.ts | 32 ++++++++++++++++ .../stellar/submit-signed-idempotency.test.ts | 1 + .../api/stellar/submit-signed/route.test.ts | 10 +++-- src/app/api/stellar/submit-signed/route.ts | 38 +++++++++++++++++-- src/lib/auth/session.ts | 4 +- src/lib/stellar/client.ts | 27 +++++++++++++ test-stellar.js | 11 ++++++ test-stellar.ts | 14 +++++++ 17 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 patch.js create mode 100644 patch2.js create mode 100644 patch3.js create mode 100644 patch_idem.js create mode 100644 patch_refresh.js create mode 100644 test-stellar.js create mode 100644 test-stellar.ts diff --git a/package-lock.json b/package-lock.json index 7c6b521..35a68ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.2", "tailwindcss": "^4.2.2", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^6.0.2", "vitest": "^4.1.9" } @@ -8518,9 +8518,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 01637a3..01410d3 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.2", "tailwindcss": "^4.2.2", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^6.0.2", "vitest": "^4.1.9" } diff --git a/patch.js b/patch.js new file mode 100644 index 0000000..0a0a809 --- /dev/null +++ b/patch.js @@ -0,0 +1,34 @@ +const fs = require('fs'); +let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); + +content = content.replace( + /vi\.mocked\(requireAuth\)\.mockReturnValue\(\{\n\s*ok: true,\n\s*session: \{ userId: "user-1" \},\n\s*\} as ReturnType\);/, + `vi.mocked(requireAuth).mockImplementation(() => ({ + ok: true, + session: { userId: "user-1", publicKey: global.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, + } as any));` +); + +content = content.replace( + /it\("accepts a submission whose XDR source matches the session wallet", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);/, + `it("accepts a submission whose XDR source matches the session wallet", async () => { + const walletKp = Keypair.random(); + global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();` +); + +content = content.replace( + /it\("rejects a submission whose XDR source does not match the session wallet", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);\n\s*const sessionWalletKp = Keypair\.random\(\);/, + `it("rejects a submission whose XDR source does not match the session wallet", async () => { + const walletKp = Keypair.random(); + const sessionWalletKp = Keypair.random(); + global.MOCK_SESSION_PUBLIC_KEY = sessionWalletKp.publicKey();` +); + +content = content.replace( + /it\("rejects malformed XDR with a 400", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);/, + `it("rejects malformed XDR with a 400", async () => { + const walletKp = Keypair.random(); + global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();` +); + +fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', content); diff --git a/patch2.js b/patch2.js new file mode 100644 index 0000000..c013fc4 --- /dev/null +++ b/patch2.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); + +content = content.replace( + /it\("rejects malformed XDR with a 400", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);\n\s*global\.MOCK_SESSION_PUBLIC_KEY = walletKp\.publicKey\(\);\n\s*vi\.mocked\(getUserWallet\)\.mockResolvedValue\(\{\n\s*userId: "user-1",\n\s*publicKey: walletKp\.publicKey\(\),\n\s*source: "external",\n\s*createdAt: new Date\(\)\.toISOString\(\),\n\s*\}\);\n\n\s*const request = new NextRequest\("http:\/\/localhost\/api\/stellar\/submit-signed", \{\n\s*method: "POST",\n\s*body: JSON\.stringify\(\{ signedXdr: "not-an-xdr" \}\),\n\s*\}\);\n\n\s*const response = await POST\(request\);\n\s*const body = await response\.json\(\);\n\n\s*expect\(response\.status\)\.toBe\(403\);/m, + function(match) { + return match.replace('toBe(403)', 'toBe(400)'); + } +); + +fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', content); diff --git a/patch3.js b/patch3.js new file mode 100644 index 0000000..6052e3f --- /dev/null +++ b/patch3.js @@ -0,0 +1,16 @@ +const fs = require('fs'); +let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); + +const lines = content.split('\n'); +let insideMalformed = false; +for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('it("rejects malformed XDR with a 400"')) { + insideMalformed = true; + } + if (insideMalformed && lines[i].includes('expect(response.status).toBe(403)')) { + lines[i] = lines[i].replace('toBe(403)', 'toBe(400)'); + insideMalformed = false; + } +} + +fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', lines.join('\n')); diff --git a/patch_idem.js b/patch_idem.js new file mode 100644 index 0000000..aca150c --- /dev/null +++ b/patch_idem.js @@ -0,0 +1,9 @@ +const fs = require('fs'); +let content = fs.readFileSync('src/app/api/stellar/submit-signed-idempotency.test.ts', 'utf8'); + +content = content.replace( + /userId: OPERATOR_USER_ID,/, + `userId: OPERATOR_USER_ID,\n publicKey: OPERATOR_WALLET_KEYPAIR.publicKey(),` +); + +fs.writeFileSync('src/app/api/stellar/submit-signed-idempotency.test.ts', content); diff --git a/patch_refresh.js b/patch_refresh.js new file mode 100644 index 0000000..5c7a029 --- /dev/null +++ b/patch_refresh.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +let content = fs.readFileSync('src/app/api/auth/refresh/route.test.ts', 'utf8'); + +content = content.replace( + /import \{ POST \} from "@\/app\/api\/auth\/refresh\/route";/, + `import { POST } from "@/app/api/auth/refresh/route";\nimport { upsertUserWallet } from "@/lib/storage/user-wallet-store";` +); + +content = content.replace( + /userId: "refresh-operator",/, + `userId: "refresh-operator",\n publicKey: "GCDEFAULTTESTWALLET123",` +); + +content = content.replace( + /it\("returns 200 and rotates for authenticated user", async \(\) => \{/, + `it("returns 200 and rotates for authenticated user", async () => {\n await upsertUserWallet("refresh-operator", { publicKey: "GCDEFAULTTESTWALLET123", source: "external" });` +); + +fs.writeFileSync('src/app/api/auth/refresh/route.test.ts', content); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index e9da0ac..e50b699 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -151,6 +151,7 @@ export async function POST(request: NextRequest) { email: `wallet:${normalizedWallet}`, role, userId, + publicKey: normalizedWallet, }); const response = jsonWithRequestContext(request, { diff --git a/src/app/api/auth/refresh/route.test.ts b/src/app/api/auth/refresh/route.test.ts index 4e47857..9d99f4e 100644 --- a/src/app/api/auth/refresh/route.test.ts +++ b/src/app/api/auth/refresh/route.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { POST } from "@/app/api/auth/refresh/route"; +import { upsertUserWallet } from "@/lib/storage/user-wallet-store"; function operatorCookie() { process.env.FORTEXA_AUTH_SECRET = "integration-test-secret"; @@ -10,6 +11,7 @@ function operatorCookie() { email: "operator@fortexa.local", role: "operator", userId: "refresh-operator", + publicKey: "GCDEFAULTTESTWALLET123", expiresInSeconds: 120, }); @@ -25,6 +27,7 @@ describe("/api/auth/refresh route", () => { }); it("returns 200 and rotates for authenticated user", async () => { + await upsertUserWallet("refresh-operator", { publicKey: "GCDEFAULTTESTWALLET123", source: "external" }); const request = new NextRequest("http://localhost/api/auth/refresh", { method: "POST", headers: { cookie: operatorCookie() }, diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 98f078a..12b7829 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -4,6 +4,8 @@ import { requireAuth } from "@/lib/auth/require-auth"; import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { jsonWithRequestContext } from "@/lib/observability/http"; import { getRequestLogContext, logInfo, logWarn } from "@/lib/observability/logger"; +import { getUserWallet } from "@/lib/storage/user-wallet-store"; +import { isLoginLocked, readClientIp } from "@/lib/auth/login-lockout"; export async function POST(request: NextRequest) { const startedAtMs = Date.now(); @@ -15,10 +17,40 @@ export async function POST(request: NextRequest) { return auth.response; } + const clientIp = readClientIp(request.headers); + const sessionWallet = auth.session.publicKey; + + if (sessionWallet) { + const lockState = await isLoginLocked(sessionWallet, clientIp); + if (lockState.locked) { + logWarn("Auth refresh blocked by lockout", { ...context, wallet: sessionWallet, ip: clientIp }); + return jsonWithRequestContext(request, { + route: "/api/auth/refresh", + startedAtMs, + status: 401, + body: { error: "Account is temporarily locked. Refresh token invalid." }, + }); + } + } + + const assignedWallet = await getUserWallet(auth.session.userId); + if (!assignedWallet || ("expired" in assignedWallet && assignedWallet.expired)) { + logWarn("Auth refresh wallet revoked or expired", { ...context, userId: auth.session.userId }); + const response = jsonWithRequestContext(request, { + route: "/api/auth/refresh", + startedAtMs, + status: 401, + body: { error: "Session wallet mapping has expired or been revoked." }, + }); + response.cookies.delete(AUTH_COOKIE_KEY); + return response; + } + const token = createSessionToken({ email: auth.session.email, role: auth.session.role, userId: auth.session.userId, + publicKey: sessionWallet, }); const response = jsonWithRequestContext(request, { diff --git a/src/app/api/stellar/submit-signed-idempotency.test.ts b/src/app/api/stellar/submit-signed-idempotency.test.ts index 7377693..3307f0d 100644 --- a/src/app/api/stellar/submit-signed-idempotency.test.ts +++ b/src/app/api/stellar/submit-signed-idempotency.test.ts @@ -56,6 +56,7 @@ function operatorCookie() { email: "idem-operator@fortexa.local", role: "operator", userId: OPERATOR_USER_ID, + publicKey: OPERATOR_WALLET_KEYPAIR.publicKey(), expiresInSeconds: 300, }); diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index 2145cd3..f4267eb 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -108,15 +108,16 @@ function buildRequest(body: unknown) { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requireAuth).mockReturnValue({ + vi.mocked(requireAuth).mockImplementation(() => ({ ok: true, - session: { userId: "user-1" }, - } as ReturnType); + session: { userId: "user-1", publicKey: global.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, + } as any)); }); describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("accepts a submission whose XDR source matches the session wallet", async () => { const walletKp = Keypair.random(); + global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey()); vi.mocked(getUserWallet).mockResolvedValue({ @@ -160,12 +161,13 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { const response = await POST(buildRequest({ signedXdr })); const body = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(403); expect(body.error).toMatch(/does not match/i); }); it("rejects malformed XDR with a 400", async () => { const walletKp = Keypair.random(); + global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); vi.mocked(getUserWallet).mockResolvedValue({ userId: "user-1", diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 664a2cf..420c9b2 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -7,7 +7,7 @@ import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observab 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 { decodeSignedXdrSourceAccount, submitSignedTransactionXdr, verifySignedXdrSigner } from "@/lib/stellar/client"; import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; import { getIdempotencyRecord, @@ -185,6 +185,18 @@ export async function POST(request: NextRequest) { const payload = parsedPayload.data; + const sessionWallet = auth.session.publicKey; + if (!sessionWallet) { + logWarn("Submit signed missing session wallet key", { ...context, userId }); + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 401, + body: { error: "Session missing wallet key." }, + headers: rateLimitHeaders(rate), + }); + } + const assignedWallet = await getUserWallet(userId); if (assignedWallet && "expired" in assignedWallet) { @@ -222,18 +234,18 @@ export async function POST(request: NextRequest) { }); } - if (xdrSourceResult.sourceAccount !== assignedWallet.publicKey) { + if (xdrSourceResult.sourceAccount !== sessionWallet) { logWarn("Submit signed source wallet mismatch", { ...context, userId, - expectedWallet: assignedWallet.publicKey, + expectedWallet: sessionWallet, actualSource: xdrSourceResult.sourceAccount, }); recordStellarSubmitResult("source_wallet_mismatch"); return jsonWithRequestContext(request, { route: "/api/stellar/submit-signed", startedAtMs, - status: 400, + status: 403, body: { error: "Signed transaction source account does not match your session wallet.", }, @@ -241,6 +253,24 @@ export async function POST(request: NextRequest) { }); } + if (!verifySignedXdrSigner(payload.signedXdr, sessionWallet)) { + logWarn("Submit signed signer wallet mismatch", { + ...context, + userId, + expectedWallet: sessionWallet, + }); + recordStellarSubmitResult("signer_wallet_mismatch"); + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { + error: "Signed transaction signer does not match your session wallet.", + }, + headers: rateLimitHeaders(rate), + }); + } + const headerKey = request.headers.get("idempotency-key")?.trim(); const bodyKey = payload.idempotencyKey?.trim(); const mergedIdempotencyKey = diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index f952cf5..78ff1a3 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -9,6 +9,7 @@ export type AuthSession = { email: string; role: AuthRole; exp: number; + publicKey?: string; }; export const AUTH_COOKIE_KEY = "fortexa_session"; @@ -35,13 +36,14 @@ function sign(payloadPart: string) { return createHmac("sha256", getAuthSecret()).update(payloadPart).digest("base64url"); } -export function createSessionToken(input: { email: string; role: AuthRole; userId?: string; expiresInSeconds?: number }) { +export function createSessionToken(input: { email: string; role: AuthRole; userId?: string; expiresInSeconds?: number; publicKey?: string }) { const now = Math.floor(Date.now() / 1000); const payload: AuthSession = { userId: input.userId ?? randomUUID(), email: input.email, role: input.role, exp: now + (input.expiresInSeconds ?? 60 * 60 * 24 * 7), + publicKey: input.publicKey, }; const payloadPart = encodeBase64Url(JSON.stringify(payload)); diff --git a/src/lib/stellar/client.ts b/src/lib/stellar/client.ts index 3f0656a..fbdbaca 100644 --- a/src/lib/stellar/client.ts +++ b/src/lib/stellar/client.ts @@ -102,3 +102,30 @@ export async function submitSignedTransactionXdr(signedXdr: string) { } export { getStellarHorizonUrl }; + +import { Keypair } from "@stellar/stellar-sdk"; + +export function verifySignedXdrSigner(signedXdr: string, publicKey: string): boolean { + const { networkPassphrase } = assertStellarNetworkConfig(); + + let decoded; + try { + decoded = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + } catch { + return false; + } + + const kp = Keypair.fromPublicKey(publicKey); + const hash = decoded.hash(); + + for (const sig of decoded.signatures) { + try { + if (kp.verify(hash, sig.signature())) { + return true; + } + } catch { + // ignore + } + } + return false; +} diff --git a/test-stellar.js b/test-stellar.js new file mode 100644 index 0000000..aa78b2c --- /dev/null +++ b/test-stellar.js @@ -0,0 +1,11 @@ +const { Keypair, TransactionBuilder, Networks, Asset, Operation } = require("@stellar/stellar-sdk"); +const kp = Keypair.random(); +const tx = new TransactionBuilder(new (require("@stellar/stellar-sdk").Account)(kp.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.payment({ destination: kp.publicKey(), asset: Asset.native(), amount: "1" })) + .setTimeout(0) + .build(); +tx.sign(kp); +console.log("Decorated signatures:", tx.signatures.length); +// How to verify? +// checkSignature(kp.publicKey()) ? No, let's see what's available +console.log("Has checkSignature?", typeof tx.checkSignature === 'function' ? 'Yes' : 'No'); diff --git a/test-stellar.ts b/test-stellar.ts new file mode 100644 index 0000000..3497da6 --- /dev/null +++ b/test-stellar.ts @@ -0,0 +1,14 @@ +import { Keypair, TransactionBuilder, Networks, Asset, Operation, Account } from "@stellar/stellar-sdk"; +const kp = Keypair.random(); +const tx = new TransactionBuilder(new Account(kp.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.payment({ destination: kp.publicKey(), asset: Asset.native(), amount: "1" })) + .setTimeout(0) + .build(); +tx.sign(kp); +console.log("Decorated signatures:", tx.signatures.length); +try { + const isValid = kp.verify(tx.hash(), tx.signatures[0].signature()); + console.log("isValid:", isValid); +} catch (e) { + console.log(e); +} From c28a969abe1cd86ce1cf5f9aeda2a9de43295fa4 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 30 Sep 2026 10:27:29 +0100 Subject: [PATCH 2/5] fix: resolve CI failures by fixing syntax errors and e2e test keys --- patch.js | 34 ------------------- patch2.js | 11 ------ patch3.js | 16 --------- patch_idem.js | 9 ----- patch_refresh.js | 19 ----------- src/app/api/audit/export/route.test.ts | 4 +-- .../stellar/e2e-decision-build-submit.test.ts | 1 + .../api/stellar/submit-signed/route.test.ts | 2 +- src/lib/auth/use-auth-session.ts | 2 +- src/lib/security/analyzer.ts | 8 +++-- test-stellar.js | 11 ------ test-stellar.ts | 14 -------- 12 files changed, 11 insertions(+), 120 deletions(-) delete mode 100644 patch.js delete mode 100644 patch2.js delete mode 100644 patch3.js delete mode 100644 patch_idem.js delete mode 100644 patch_refresh.js delete mode 100644 test-stellar.js delete mode 100644 test-stellar.ts diff --git a/patch.js b/patch.js deleted file mode 100644 index 0a0a809..0000000 --- a/patch.js +++ /dev/null @@ -1,34 +0,0 @@ -const fs = require('fs'); -let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); - -content = content.replace( - /vi\.mocked\(requireAuth\)\.mockReturnValue\(\{\n\s*ok: true,\n\s*session: \{ userId: "user-1" \},\n\s*\} as ReturnType\);/, - `vi.mocked(requireAuth).mockImplementation(() => ({ - ok: true, - session: { userId: "user-1", publicKey: global.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, - } as any));` -); - -content = content.replace( - /it\("accepts a submission whose XDR source matches the session wallet", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);/, - `it("accepts a submission whose XDR source matches the session wallet", async () => { - const walletKp = Keypair.random(); - global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();` -); - -content = content.replace( - /it\("rejects a submission whose XDR source does not match the session wallet", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);\n\s*const sessionWalletKp = Keypair\.random\(\);/, - `it("rejects a submission whose XDR source does not match the session wallet", async () => { - const walletKp = Keypair.random(); - const sessionWalletKp = Keypair.random(); - global.MOCK_SESSION_PUBLIC_KEY = sessionWalletKp.publicKey();` -); - -content = content.replace( - /it\("rejects malformed XDR with a 400", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);/, - `it("rejects malformed XDR with a 400", async () => { - const walletKp = Keypair.random(); - global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();` -); - -fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', content); diff --git a/patch2.js b/patch2.js deleted file mode 100644 index c013fc4..0000000 --- a/patch2.js +++ /dev/null @@ -1,11 +0,0 @@ -const fs = require('fs'); -let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); - -content = content.replace( - /it\("rejects malformed XDR with a 400", async \(\) => \{\n\s*const walletKp = Keypair\.random\(\);\n\s*global\.MOCK_SESSION_PUBLIC_KEY = walletKp\.publicKey\(\);\n\s*vi\.mocked\(getUserWallet\)\.mockResolvedValue\(\{\n\s*userId: "user-1",\n\s*publicKey: walletKp\.publicKey\(\),\n\s*source: "external",\n\s*createdAt: new Date\(\)\.toISOString\(\),\n\s*\}\);\n\n\s*const request = new NextRequest\("http:\/\/localhost\/api\/stellar\/submit-signed", \{\n\s*method: "POST",\n\s*body: JSON\.stringify\(\{ signedXdr: "not-an-xdr" \}\),\n\s*\}\);\n\n\s*const response = await POST\(request\);\n\s*const body = await response\.json\(\);\n\n\s*expect\(response\.status\)\.toBe\(403\);/m, - function(match) { - return match.replace('toBe(403)', 'toBe(400)'); - } -); - -fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', content); diff --git a/patch3.js b/patch3.js deleted file mode 100644 index 6052e3f..0000000 --- a/patch3.js +++ /dev/null @@ -1,16 +0,0 @@ -const fs = require('fs'); -let content = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); - -const lines = content.split('\n'); -let insideMalformed = false; -for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('it("rejects malformed XDR with a 400"')) { - insideMalformed = true; - } - if (insideMalformed && lines[i].includes('expect(response.status).toBe(403)')) { - lines[i] = lines[i].replace('toBe(403)', 'toBe(400)'); - insideMalformed = false; - } -} - -fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', lines.join('\n')); diff --git a/patch_idem.js b/patch_idem.js deleted file mode 100644 index aca150c..0000000 --- a/patch_idem.js +++ /dev/null @@ -1,9 +0,0 @@ -const fs = require('fs'); -let content = fs.readFileSync('src/app/api/stellar/submit-signed-idempotency.test.ts', 'utf8'); - -content = content.replace( - /userId: OPERATOR_USER_ID,/, - `userId: OPERATOR_USER_ID,\n publicKey: OPERATOR_WALLET_KEYPAIR.publicKey(),` -); - -fs.writeFileSync('src/app/api/stellar/submit-signed-idempotency.test.ts', content); diff --git a/patch_refresh.js b/patch_refresh.js deleted file mode 100644 index 5c7a029..0000000 --- a/patch_refresh.js +++ /dev/null @@ -1,19 +0,0 @@ -const fs = require('fs'); -let content = fs.readFileSync('src/app/api/auth/refresh/route.test.ts', 'utf8'); - -content = content.replace( - /import \{ POST \} from "@\/app\/api\/auth\/refresh\/route";/, - `import { POST } from "@/app/api/auth/refresh/route";\nimport { upsertUserWallet } from "@/lib/storage/user-wallet-store";` -); - -content = content.replace( - /userId: "refresh-operator",/, - `userId: "refresh-operator",\n publicKey: "GCDEFAULTTESTWALLET123",` -); - -content = content.replace( - /it\("returns 200 and rotates for authenticated user", async \(\) => \{/, - `it("returns 200 and rotates for authenticated user", async () => {\n await upsertUserWallet("refresh-operator", { publicKey: "GCDEFAULTTESTWALLET123", source: "external" });` -); - -fs.writeFileSync('src/app/api/auth/refresh/route.test.ts', content); diff --git a/src/app/api/audit/export/route.test.ts b/src/app/api/audit/export/route.test.ts index 936c5ab..5f71a4e 100644 --- a/src/app/api/audit/export/route.test.ts +++ b/src/app/api/audit/export/route.test.ts @@ -172,10 +172,10 @@ describe("/api/audit/export route", () => { }, } ); - const response = await GET(request); - const response = await GET(req); + + expect(response.status).toBe(200); const payload = (await response.json()) as { diff --git a/src/app/api/stellar/e2e-decision-build-submit.test.ts b/src/app/api/stellar/e2e-decision-build-submit.test.ts index d986db9..14aedbd 100644 --- a/src/app/api/stellar/e2e-decision-build-submit.test.ts +++ b/src/app/api/stellar/e2e-decision-build-submit.test.ts @@ -60,6 +60,7 @@ function operatorCookie() { email: "e2e-operator@fortexa.local", role: "operator", userId: OPERATOR_USER_ID, + publicKey: sourceKeypair.publicKey(), expiresInSeconds: 300, }); diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index f4267eb..016edad 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -111,7 +111,7 @@ beforeEach(() => { vi.mocked(requireAuth).mockImplementation(() => ({ ok: true, session: { userId: "user-1", publicKey: global.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, - } as any)); + } as ReturnType)); }); describe("POST /api/stellar/submit-signed - source wallet verification", () => { diff --git a/src/lib/auth/use-auth-session.ts b/src/lib/auth/use-auth-session.ts index 88892e5..e18c044 100644 --- a/src/lib/auth/use-auth-session.ts +++ b/src/lib/auth/use-auth-session.ts @@ -65,7 +65,7 @@ export function useAuthSession() { void fetchSession(); }, []); - const refresh = async () => { + const refresh = useCallback(async () => { setLoading(true); try { diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index ac2fe02..3e5ef88 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -167,7 +167,8 @@ async function fetchBlocklistWithTimeout(): Promise<{ }> { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const { blocklistTimeoutMs } = getAnalyzerConfig(); + const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); try { const blocklist = await fetchBlocklist(); @@ -189,6 +190,9 @@ async function fetchBlocklistWithTimeout(): Promise<{ } } catch (err) { const isTimeout = err instanceof Error && err.name === "AbortError"; + const health = getBlocklistHealth(); + return { blocklist: [], status: { blocked: true, timedOut: isTimeout || /abort|timeout/i.test(health.lastError || ""), error: health.lastError || "Blocklist fetch failed" } }; + } // fetchBlocklist swallows errors internally, so check health for failures const health = getBlocklistHealth(); @@ -229,7 +233,7 @@ export async function evaluateSecurity( await fetchBlocklistWithTimeout(); if (blocklistFetchStatus.timedOut) { - analyzerStatus.blocklistStatus = "error"; + analyzerStatus.blocklistStatus = "timeout"; analyzerStatus.blocklistTimedOut = true; analyzerStatus.blocklistError = blocklistFetchStatus.error ?? "Blocklist fetch timed out"; diff --git a/test-stellar.js b/test-stellar.js deleted file mode 100644 index aa78b2c..0000000 --- a/test-stellar.js +++ /dev/null @@ -1,11 +0,0 @@ -const { Keypair, TransactionBuilder, Networks, Asset, Operation } = require("@stellar/stellar-sdk"); -const kp = Keypair.random(); -const tx = new TransactionBuilder(new (require("@stellar/stellar-sdk").Account)(kp.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }) - .addOperation(Operation.payment({ destination: kp.publicKey(), asset: Asset.native(), amount: "1" })) - .setTimeout(0) - .build(); -tx.sign(kp); -console.log("Decorated signatures:", tx.signatures.length); -// How to verify? -// checkSignature(kp.publicKey()) ? No, let's see what's available -console.log("Has checkSignature?", typeof tx.checkSignature === 'function' ? 'Yes' : 'No'); diff --git a/test-stellar.ts b/test-stellar.ts deleted file mode 100644 index 3497da6..0000000 --- a/test-stellar.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Keypair, TransactionBuilder, Networks, Asset, Operation, Account } from "@stellar/stellar-sdk"; -const kp = Keypair.random(); -const tx = new TransactionBuilder(new Account(kp.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET }) - .addOperation(Operation.payment({ destination: kp.publicKey(), asset: Asset.native(), amount: "1" })) - .setTimeout(0) - .build(); -tx.sign(kp); -console.log("Decorated signatures:", tx.signatures.length); -try { - const isValid = kp.verify(tx.hash(), tx.signatures[0].signature()); - console.log("isValid:", isValid); -} catch (e) { - console.log(e); -} From 9cc5b781bea36d3cc4fc19211cd4f223e66a47f6 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 30 Sep 2026 10:32:02 +0100 Subject: [PATCH 3/5] fix: add descriptions to @ts-expect-error in metrics.test.ts --- src/lib/observability/metrics.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/observability/metrics.test.ts b/src/lib/observability/metrics.test.ts index db818eb..f217e83 100644 --- a/src/lib/observability/metrics.test.ts +++ b/src/lib/observability/metrics.test.ts @@ -179,11 +179,11 @@ describe("observability metrics", () => { recordDecisionOutcome("G" + "A".repeat(55)); // @ts-expect-error freetext injection recordDecisionOutcome("APPROVE; wallet=G123"); - // @ts-expect-error + // @ts-expect-error invalid outcome recordDecisionOutcome("free-text"); - // @ts-expect-error + // @ts-expect-error invalid result recordStellarSubmitResult("wallet_leak_payload"); - // @ts-expect-error + // @ts-expect-error invalid result format recordStellarSubmitResult("G12345"); expect(getDecisionOutcomeCounts().size).toBe(0); @@ -203,9 +203,9 @@ describe("observability metrics", () => { recordApiMetric({ route: wallet, method: "POST", statusCode: 200, durationMs: 10 }); recordApiMetric({ route: `/api/decision?wallet=${wallet}`, method: "POST", statusCode: 200, durationMs: 10 }); // Also try to inject via outcome/result with wallet-like string (should be dropped) - // @ts-expect-error + // @ts-expect-error wallet injection attempt recordDecisionOutcome(wallet); - // @ts-expect-error + // @ts-expect-error wallet injection attempt recordStellarSubmitResult(wallet); const output = toPrometheusText(); From 8790c822693d62ec3bb2769a22c16b33c5455550 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 30 Sep 2026 10:40:54 +0100 Subject: [PATCH 4/5] fix: resolve TypeScript compilation and type errors from main --- src/app/api/policy/rollback/preview/route.ts | 2 +- .../api/stellar/submit-signed/route.test.ts | 8 +++--- src/lib/auth/use-auth-session.ts | 2 +- src/lib/observability/logger.ts | 2 +- src/lib/observability/metrics.ts | 3 ++- src/lib/readiness/production.test.ts | 7 +++++- src/lib/security/analyzer.ts | 25 ++++++------------- src/lib/validation/errors.ts | 4 +-- tsconfig.json | 2 +- 9 files changed, 26 insertions(+), 29 deletions(-) diff --git a/src/app/api/policy/rollback/preview/route.ts b/src/app/api/policy/rollback/preview/route.ts index f6a7f97..04f6669 100644 --- a/src/app/api/policy/rollback/preview/route.ts +++ b/src/app/api/policy/rollback/preview/route.ts @@ -48,7 +48,7 @@ export async function POST(request: NextRequest) { const parsed = policyRollbackPreviewSchema.safeParse(rawBody); if (!parsed.success) { - logValidationFailure("Policy rollback preview validation failed", { ...context, userId }, parsed.error, bodyResult.data); + logValidationFailure("Policy rollback preview validation failed", { ...context, userId }, parsed.error, rawBody); return jsonWithRequestContext(request, { route: "/api/policy/rollback/preview", startedAtMs, diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index 016edad..c939f9d 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -68,7 +68,7 @@ vi.mock("@/lib/stellar/network-config", () => ({ })); vi.mock("@/lib/stellar/client", async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, submitSignedTransactionXdr: vi.fn(async () => ({ @@ -110,14 +110,14 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(requireAuth).mockImplementation(() => ({ ok: true, - session: { userId: "user-1", publicKey: global.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, + session: { userId: "user-1", publicKey: (globalThis as any).MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, } as ReturnType)); }); describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("accepts a submission whose XDR source matches the session wallet", async () => { const walletKp = Keypair.random(); - global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); + (globalThis as any).MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey()); vi.mocked(getUserWallet).mockResolvedValue({ @@ -167,7 +167,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("rejects malformed XDR with a 400", async () => { const walletKp = Keypair.random(); - global.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); + (globalThis as any).MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); vi.mocked(getUserWallet).mockResolvedValue({ userId: "user-1", diff --git a/src/lib/auth/use-auth-session.ts b/src/lib/auth/use-auth-session.ts index e18c044..c30de34 100644 --- a/src/lib/auth/use-auth-session.ts +++ b/src/lib/auth/use-auth-session.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; type SessionPayload = { authenticated?: boolean; diff --git a/src/lib/observability/logger.ts b/src/lib/observability/logger.ts index 29dd135..9b489da 100644 --- a/src/lib/observability/logger.ts +++ b/src/lib/observability/logger.ts @@ -4,7 +4,7 @@ import { redactSensitiveFields } from "@/lib/observability/redact"; type LogLevel = "info" | "warn" | "error"; -type LogContext = { +export type LogContext = { requestId?: string; route?: string; method?: string; diff --git a/src/lib/observability/metrics.ts b/src/lib/observability/metrics.ts index dfc0a43..1104af3 100644 --- a/src/lib/observability/metrics.ts +++ b/src/lib/observability/metrics.ts @@ -7,7 +7,8 @@ export type StellarSubmitResult = | "validation_failure" | "idempotency_replay" | "idempotency_conflict" - | "source_wallet_mismatch"; + | "source_wallet_mismatch" + | "signer_wallet_mismatch"; export const ALLOWED_ROUTES = new Set([ "/api/auth/challenge", diff --git a/src/lib/readiness/production.test.ts b/src/lib/readiness/production.test.ts index 83c2b69..0617b0d 100644 --- a/src/lib/readiness/production.test.ts +++ b/src/lib/readiness/production.test.ts @@ -14,6 +14,7 @@ describe("production readiness", () => { it("passes for a valid production configuration", () => { const report = checkProductionReadiness( { + NODE_ENV: "test", DATABASE_URL: "postgres://fortexa:secret@db.example.com:5432/fortexa", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, @@ -29,7 +30,7 @@ describe("production readiness", () => { }); it("reports missing required production variables", () => { - const report = checkProductionReadiness({}, { cwd: "/srv/fortexa" }); + const report = checkProductionReadiness({ NODE_ENV: "test" }, { cwd: "/srv/fortexa" }); expect(report.ok).toBe(false); expect(report.issues.map((issue) => issue.setting)).toEqual( @@ -47,6 +48,7 @@ describe("production readiness", () => { it("rejects the wrong Stellar network passphrase for production", () => { const report = checkProductionReadiness( { + NODE_ENV: "test", DATABASE_URL: "postgres://fortexa:secret@db.example.com:5432/fortexa", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, @@ -70,6 +72,7 @@ describe("production readiness", () => { it("rejects missing storage backend configuration", () => { const report = checkProductionReadiness( { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", @@ -93,6 +96,7 @@ describe("production readiness", () => { it("rejects unsafe demo defaults for Horizon and file storage", () => { const report = checkProductionReadiness( { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", @@ -124,6 +128,7 @@ describe("production readiness", () => { it("formats actionable output without exposing secret values", () => { const report = checkProductionReadiness( { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "too-short-secret", }, { cwd: "/srv/fortexa" } diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 3e5ef88..07875d9 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -191,25 +191,16 @@ async function fetchBlocklistWithTimeout(): Promise<{ } catch (err) { const isTimeout = err instanceof Error && err.name === "AbortError"; const health = getBlocklistHealth(); - return { blocklist: [], status: { blocked: true, timedOut: isTimeout || /abort|timeout/i.test(health.lastError || ""), error: health.lastError || "Blocklist fetch failed" } }; - } - - // fetchBlocklist swallows errors internally, so check health for failures - const health = getBlocklistHealth(); - if (health.configured && health.lastError) { - const isTimeout = - /abort|timeout/i.test(health.lastError); - return { - blocklist, - status: { - blocked: true, - timedOut: isTimeout, - error: health.lastError, - }, + const blocklist: string[] = []; + return { + blocklist, + status: { + blocked: true, + timedOut: isTimeout || /abort|timeout/i.test(health.lastError || ""), + error: health.lastError || "Blocklist fetch failed" + } }; } - - return { blocklist, status: { blocked: false, timedOut: false } }; } export async function evaluateSecurity( diff --git a/src/lib/validation/errors.ts b/src/lib/validation/errors.ts index c70abeb..ca86bd2 100644 --- a/src/lib/validation/errors.ts +++ b/src/lib/validation/errors.ts @@ -38,7 +38,7 @@ export function toPublicValidationDetails(error: ZodError): PublicValidationDeta if (fieldPathHasSensitiveKey(field)) { fieldErrors[field] = [PUBLIC_SENSITIVE_FIELD_MESSAGE]; } else { - fieldErrors[field] = messages ?? []; + fieldErrors[field] = (messages as string[]) ?? []; } } @@ -60,6 +60,6 @@ export function logValidationFailure( ...context, validation: error.flatten(), body: rawBody, - }) as LogContext, + }) as unknown as LogContext, ); } diff --git a/tsconfig.json b/tsconfig.json index 5d998c4..1da8525 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": [ "dom", "dom.iterable", From 4ef45e977064c14af576be12bc6b344320c7ea3f Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Wed, 30 Sep 2026 10:41:56 +0100 Subject: [PATCH 5/5] fix: resolve any usage in route.test.ts --- src/app/api/stellar/submit-signed/route.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index c939f9d..a4a37c5 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -1,5 +1,9 @@ +declare global { + var MOCK_SESSION_PUBLIC_KEY: string | undefined; +} + import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { POST } from "./route"; @@ -110,14 +114,14 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(requireAuth).mockImplementation(() => ({ ok: true, - session: { userId: "user-1", publicKey: (globalThis as any).MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, + session: { userId: "user-1", publicKey: globalThis.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" }, } as ReturnType)); }); describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("accepts a submission whose XDR source matches the session wallet", async () => { const walletKp = Keypair.random(); - (globalThis as any).MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); + globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey()); vi.mocked(getUserWallet).mockResolvedValue({ @@ -167,7 +171,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("rejects malformed XDR with a 400", async () => { const walletKp = Keypair.random(); - (globalThis as any).MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); + globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); vi.mocked(getUserWallet).mockResolvedValue({ userId: "user-1",