diff --git a/package-lock.json b/package-lock.json index 7c6b5212..35a68cae 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 01637a32..01410d3e 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/src/app/api/audit/export/route.test.ts b/src/app/api/audit/export/route.test.ts index 9380b398..05053ec8 100644 --- a/src/app/api/audit/export/route.test.ts +++ b/src/app/api/audit/export/route.test.ts @@ -176,6 +176,8 @@ describe("/api/audit/export route", () => { ); const response = await GET(req); + + expect(response.status).toBe(200); const payload = (await response.json()) as { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c1ace037..876ae98f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -179,6 +179,7 @@ export async function POST(request: NextRequest) { role: roles.includes("operator") ? "operator" : roles[0], roles, 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 b69b2d08..cd61e7ac 100644 --- a/src/app/api/auth/refresh/route.test.ts +++ b/src/app/api/auth/refresh/route.test.ts @@ -9,6 +9,7 @@ import { LOGKED_OUT.ERROR_CODE, } from "@/lib/auth/login-lockout"; 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"; @@ -16,6 +17,7 @@ function operatorCookie() { email: "operator@fortexa.local", role: "operator", userId: "refresh-operator", + publicKey: "GCDEFAULTTESTWALLET123", expiresInSeconds: 120, }); @@ -42,6 +44,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 f4e03b8d..c533d000 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -4,6 +4,8 @@ import { requireActiveAuth } 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(); @@ -17,28 +19,39 @@ export async function POST(request: NextRequest) { } const clientIp = readClientIp(request.headers); - const lockState = await isLoginLocked(auth.session.userId, clientIp); - if (lockState.locked) { - logWarn("Auth refresh blocked by lockout", { - ...context, - userId: auth.session.userId, - ip: clientIp, - }); - const retryAfterSeconds = Math.max(1, lockState.retryAfterSeconds); - return jsonWithRequestContext(request, { + 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: 423, - body: { error: LOCKED_ERROR, retryAfterSeconds: retryAfterSeconds }, - headers: { "Retry-After": String(retryAfterSeconds) }, + 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, - sessionId: auth.session.sid, + publicKey: sessionWallet, }); const response = jsonWithRequestContext(request, { 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 c749a322..f24b378f 100644 --- a/src/app/api/stellar/e2e-decision-build-submit.test.ts +++ b/src/app/api/stellar/e2e-decision-build-submit.test.ts @@ -74,6 +74,7 @@ function operatorCookie() { role: "operator", roles: ["operator", "signer"], userId: OPERATOR_USER_ID, + publicKey: sourceKeypair.publicKey(), expiresInSeconds: 300, }); diff --git a/src/app/api/stellar/submit-signed-idempotency.test.ts b/src/app/api/stellar/submit-signed-idempotency.test.ts index 5314efd1..7db4d091 100644 --- a/src/app/api/stellar/submit-signed-idempotency.test.ts +++ b/src/app/api/stellar/submit-signed-idempotency.test.ts @@ -98,6 +98,7 @@ function operatorCookie() { email: "idem-operator@fortexa.local", role: "signer", 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 379c7126..d057aca0 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -1,13 +1,9 @@ -import { - Account, - Asset, - Keypair, - Memo, - Networks, - Operation, - TransactionBuilder, -} from "@stellar/stellar-sdk"; -import { NextRequest, NextResponse } from "next/server"; +declare global { + var MOCK_SESSION_PUBLIC_KEY: string | undefined; +} + +import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; +import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { POST } from "./route"; @@ -165,33 +161,17 @@ function makeDecisionReceipt( beforeEach(() => { vi.clearAllMocks(); - getAuditEntryMock.mockResolvedValue({ - id: "00000000-0000-4000-8000-000000000000", - paymentQuote: { memo: "fortexa:test-action" }, - }); - verifyQuoteMock.mockReturnValue({ ok: true, quote: {} }); - vi.mocked(requireAuth).mockReturnValue({ + vi.mocked(requireAuth).mockImplementation(() => ({ ok: true, - session: { userId: "user-1" }, - } as ReturnType); + 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 and the decision receipt", async () => { const walletKp = Keypair.random(); - const destination = Keypair.random().publicKey(); - const signedXdr = buildSignedXdr( - walletKp, - walletKp.publicKey(), - destination, - "1", - "approved-memo", - ); - const decisionReceipt = makeDecisionReceipt( - destination, - "1", - "approved-memo", - ); + globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey(); + const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey()); vi.mocked(getUserWallet).mockResolvedValue({ userId: "user-1", @@ -295,7 +275,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { const response = await POST(buildRequest({ signedXdr, decisionReceipt })); const body = await response.json(); - expect(response.status).toBe(400); + expect(response.status).toBe(403); expect(body.error).toMatch(/does not match/i); }); @@ -332,6 +312,7 @@ describe("POST /api/stellar/submit-signed - source wallet verification", () => { it("rejects malformed XDR with a 400", async () => { const walletKp = Keypair.random(); + globalThis.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 5517daa8..0db5f30f 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -14,6 +14,8 @@ import { import { recordStellarSubmitResult } from "@/lib/observability/metrics"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; +import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr, verifySignedXdrSigner } from "@/lib/stellar/client"; +import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr, @@ -371,6 +373,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) { @@ -411,39 +425,12 @@ export async function POST(request: NextRequest) { }); } - const receiptCheck = verifySignedXdrMatchesDecisionReceipt( - payload.signedXdr, - payload.decisionReceipt, - ); - 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) { - logWarn("Submit signed payload too large", { ...context, userId }); - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: receiptCheck.status ?? 403, - body: { - error: receiptCheck.error, - field: receiptCheck.field, - receiptHash: payload.decisionReceipt.receiptHash, - }, - headers: rateLimitHeaders(rate), + if (xdrSourceResult.sourceAccount !== sessionWallet) { + logWarn("Submit signed source wallet mismatch", { + ...context, + userId, + expectedWallet: sessionWallet, + actualSource: xdrSourceResult.sourceAccount, }); } @@ -454,7 +441,7 @@ export async function POST(request: NextRequest) { 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.", @@ -463,104 +450,20 @@ export async function POST(request: NextRequest) { }); } - const payload = parsedPayload.data; - - if (!xdrSourceResult.payment) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: 403, - body: { error: "Signed transaction does not contain one authorized native payment." }, - headers: rateLimitHeaders(rate), - }); - } - - const auditEntry = await getAuditEntryById(userId, payload.auditEntryId); - if (!auditEntry?.paymentQuote || xdrSourceResult.payment.memo !== auditEntry.paymentQuote.memo) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: 403, - body: { error: "Signed transaction memo does not match the authorized payment quote." }, - headers: rateLimitHeaders(rate), - }); - } - - const authorization = verifyPaymentAgainstQuote(auditEntry, { - ...xdrSourceResult.payment, - network: "testnet", - }); - if (!authorization.ok) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: authorization.status, - body: { error: authorization.error, field: authorization.field }, - headers: rateLimitHeaders(rate), - }); - } - - if (!xdrSourceResult.payment) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: 403, - body: { error: "Signed transaction does not contain one authorized native payment." }, - headers: rateLimitHeaders(rate), - }); - } - - const auditEntry = await getAuditEntryById(userId, payload.auditEntryId); - if (!auditEntry?.paymentQuote || xdrSourceResult.payment.memo !== auditEntry.paymentQuote.memo) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: 403, - body: { error: "Signed transaction memo does not match the authorized payment quote." }, - headers: rateLimitHeaders(rate), - }); - } - - const authorization = verifyPaymentAgainstQuote(auditEntry, { - ...xdrSourceResult.payment, - network: "testnet", - }); - if (!authorization.ok) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: authorization.status, - body: { error: authorization.error, field: authorization.field }, - headers: rateLimitHeaders(rate), - }); - } - - const auditEntry = await getAuditEntryById(userId, payload.decisionId); - if (!isPaymentDecisionCurrent(auditEntry, payload.decisionId, payload.quoteExpiresAt)) { - return jsonWithRequestContext(request, { - route: "/api/stellar/submit-signed", - startedAtMs, - status: 403, - body: { error: "Payment decision or quote is no longer valid. Please re-evaluate the action." }, - headers: rateLimitHeaders(rate), + if (!verifySignedXdrSigner(payload.signedXdr, sessionWallet)) { + logWarn("Submit signed signer wallet mismatch", { + ...context, + userId, + expectedWallet: sessionWallet, }); - } - - const transactionHash = getTransactionHash( - payload.signedXdr, - assertStellarNetworkConfig().networkPassphrase, - ); - if (!verifyBuildAuthorization(payload.buildAuthorization, { - userId, - decisionId: payload.decisionId, - quoteExpiresAt: payload.quoteExpiresAt, - transactionHash, - })) { + recordStellarSubmitResult("signer_wallet_mismatch"); return jsonWithRequestContext(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 403, - body: { error: "Signed transaction does not match the authorized payment build." }, + body: { + error: "Signed transaction signer does not match your session wallet.", + }, headers: rateLimitHeaders(rate), }); } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 9935e0d9..dccf89f9 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -14,12 +14,7 @@ export type AuthSession = { */ sid: string; exp: number; - gen: number; - sid: string | null; -}; - -export type VerifySessionTokenOptions = { - allowStaleGeneration?: boolean; + publicKey?: string; }; export const AUTH_COOKIE_KEY = "fortexa_session"; @@ -49,20 +44,14 @@ function sign(payloadPart: string) { return createHmac("sha256", getAuthSecret()).update(payloadPart).digest("base64url"); } -export function createSessionToken(input: { - email: string; - role: AuthRole; - userId?: string; - sessionId?: 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, - sid: input.sessionId ?? randomUUID(), - exp: now + (input.expiresInSeconds ?? SESSION_MAX_AGE_SECONDS), + exp: now + (input.expiresInSeconds ?? 60 * 60 * 24 * 7), + publicKey: input.publicKey, }; const payloadPart = encodeBase64Url(JSON.stringify(payload)); diff --git a/src/lib/observability/metrics.test.ts b/src/lib/observability/metrics.test.ts index 102578cf..37488259 100644 --- a/src/lib/observability/metrics.test.ts +++ b/src/lib/observability/metrics.test.ts @@ -354,11 +354,11 @@ describe("observability metrics", () => { recordDecisionOutcome("G" + "A".repeat(55)); // @ts-expect-error: testing freetext injection guard recordDecisionOutcome("APPROVE; wallet=G123"); - // @ts-expect-error free-text outcome must be rejected by the runtime guard + // @ts-expect-error invalid outcome recordDecisionOutcome("free-text"); - // @ts-expect-error wallet-like payload must be rejected by the runtime guard + // @ts-expect-error invalid result recordStellarSubmitResult("wallet_leak_payload"); - // @ts-expect-error raw wallet address must be rejected by the runtime guard + // @ts-expect-error invalid result format recordStellarSubmitResult("G12345"); expect(getDecisionOutcomeCounts().size).toBe(0); @@ -378,9 +378,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 wallet address cannot be recorded as an outcome + // @ts-expect-error wallet injection attempt recordDecisionOutcome(wallet); - // @ts-expect-error wallet address cannot be recorded as a submit result + // @ts-expect-error wallet injection attempt recordStellarSubmitResult(wallet); const output = toPrometheusText(); diff --git a/src/lib/observability/metrics.ts b/src/lib/observability/metrics.ts index 16119fb6..639f3b7a 100644 --- a/src/lib/observability/metrics.ts +++ b/src/lib/observability/metrics.ts @@ -9,7 +9,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 ad83718a..249aacf4 100644 --- a/src/lib/readiness/production.test.ts +++ b/src/lib/readiness/production.test.ts @@ -13,7 +13,9 @@ const VALID_OPERATOR_WALLET = describe("production readiness", () => { it("passes for a valid production configuration", () => { - const report = checkProductionReadiness({ + 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 +31,7 @@ describe("production readiness", () => { }); it("reports missing required production variables", () => { - const report = checkProductionReadiness({} as unknown as NodeJS.ProcessEnv, { 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( @@ -45,7 +47,9 @@ describe("production readiness", () => { }); it("rejects the wrong Stellar network passphrase for production", () => { - const report = checkProductionReadiness({ + 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, @@ -67,7 +71,9 @@ describe("production readiness", () => { }); it("rejects missing storage backend configuration", () => { - const report = checkProductionReadiness({ + const report = checkProductionReadiness( + { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", @@ -89,7 +95,9 @@ describe("production readiness", () => { }); it("rejects unsafe demo defaults for Horizon and file storage", () => { - const report = checkProductionReadiness({ + const report = checkProductionReadiness( + { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "0123456789abcdef0123456789abcdef", FORTEXA_OPERATOR_WALLETS: VALID_OPERATOR_WALLET, FORTEXA_SHARED_STATE_PATH: "shared/security-state.json", @@ -119,7 +127,9 @@ describe("production readiness", () => { }); it("formats actionable output without exposing secret values", () => { - const report = checkProductionReadiness({ + const report = checkProductionReadiness( + { + NODE_ENV: "test", FORTEXA_AUTH_SECRET: "too-short-secret", } as unknown as NodeJS.ProcessEnv, { cwd: "/srv/fortexa" } diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 7d19611f..76a4fcde 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -164,23 +164,39 @@ async function fetchBlocklistWithTimeout(): Promise<{ const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); try { - const blocklist = await fetchBlocklist(); - return { blocklist, status: { blocked: false, timedOut: false } }; + const controller = new AbortController(); + const { blocklistTimeoutMs } = getAnalyzerConfig(); + const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); + + try { + const blocklist = await fetchBlocklist(); + clearTimeout(timeoutId); + const health = getBlocklistHealth(); + if (health.lastError) { + return { + blocklist, + status: { + blocked: true, + timedOut: false, + error: health.lastError, + }, + }; + } + return { blocklist, status: { blocked: false, timedOut: false } }; + } finally { + clearTimeout(timeoutId); + } } catch (err) { - // fetchBlocklist records the failure in its health before rethrowing, so - // read the health back to classify the failure instead of the error alone. + const isTimeout = err instanceof Error && err.name === "AbortError"; const health = getBlocklistHealth(); - const error = - health.lastError ?? - (err instanceof Error ? err.message : "Unknown fetch error"); - const timedOut = - controller.signal.aborted || - (err instanceof Error && err.name === "AbortError") || - /abort|timeout/i.test(error); - - return { - blocklist: [], - status: { blocked: true, timedOut, error }, + const blocklist: string[] = []; + return { + blocklist, + status: { + blocked: true, + timedOut: isTimeout || /abort|timeout/i.test(health.lastError || ""), + error: health.lastError || "Blocklist fetch failed" + } }; } finally { clearTimeout(timeoutId); diff --git a/src/lib/stellar/client.ts b/src/lib/stellar/client.ts index 858640bf..732227e9 100644 --- a/src/lib/stellar/client.ts +++ b/src/lib/stellar/client.ts @@ -123,3 +123,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/src/lib/validation/errors.ts b/src/lib/validation/errors.ts index 4339ff70..ecd4c421 100644 --- a/src/lib/validation/errors.ts +++ b/src/lib/validation/errors.ts @@ -44,7 +44,7 @@ export function toPublicValidationDetails(error: ZodError): PublicValidationDeta if (fieldPathHasSensitiveKey(field)) { fieldErrors[field] = [PUBLIC_SENSITIVE_FIELD_MESSAGE]; } else { - fieldErrors[field] = Array.isArray(messages) ? messages.map(String) : []; + fieldErrors[field] = (messages as string[]) ?? []; } } @@ -62,9 +62,12 @@ export function logValidationFailure( error: ZodError, rawBody?: unknown, ): void { - logWarn(message, redactSensitiveFields({ - ...context, - validation: error.flatten(), - body: rawBody, - })); + logWarn( + message, + redactSensitiveFields({ + ...context, + validation: error.flatten(), + body: rawBody, + }) as unknown as LogContext, + ); }