Skip to content
Closed
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
28 changes: 20 additions & 8 deletions src/app/api/decision/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";

import { requireAuth } from "@/lib/auth/require-auth";
import { decisionAmountStroops, evaluateDecision } from "@/lib/decision/engine";
import { buildPaymentComparisonFromQuoteInput, decisionAmountStroops, evaluateDecision } from "@/lib/decision/engine";
import { jsonWithRequestContext } from "@/lib/observability/http";
import {
getRequestLogContext,
Expand All @@ -15,6 +15,7 @@ import { readinessBlockResponse } from "@/lib/readiness/guard";
import { recordDecisionOutcome } from "@/lib/observability/metrics";
import { redactSensitiveFields } from "@/lib/observability/redact";
import { demoScenarios } from "@/lib/scenarios/seed";
import { enforceRequestGate, gateErrorHeaders } from "@/lib/security/enforcement-gate";
import { rateLimitHeaders } from "@/lib/security/rate-limit";
import {
appendAuditEntry,
Expand All @@ -25,6 +26,7 @@ import { getUserWallet } from "@/lib/storage/user-wallet-store";
import { getPolicyConfig } from "@/lib/storage/policy-store";
import { buildPaymentQuoteFromDecision } from "@/lib/stellar/verify-payment-quote";
import type { AuditEntry } from "@/lib/types/domain";
import { formatValidationErrorResponse, logValidationFailure, VALIDATION_ERROR_CODES } from "@/lib/validation/errors";
import { decisionRequestSchema } from "@/lib/validation/schemas";

export async function POST(request: NextRequest) {
Expand Down Expand Up @@ -65,7 +67,7 @@ export async function POST(request: NextRequest) {
actionDomain: typeof rawActionDomain === "string" ? rawActionDomain : undefined,
});

if (!rate.ok) {
if (!gate.ok) {
logWarn("Decision route rate limited", context);
return respond(request, {
route: "/api/decision",
Expand All @@ -90,8 +92,17 @@ export async function POST(request: NextRequest) {
}

const userId = auth.session.userId;
const assignedWallet = await getUserWallet(userId);
if (!assignedWallet || "expired" in assignedWallet) {
return respond(request, {
route: "/api/decision",
startedAtMs,
status: 401,
body: { error: "No active wallet mapping found for this user." },
headers: rateLimitHeaders(rate),
});
}

const rawBody = (await request.json().catch(() => ({}))) as unknown;
requestBody = rawBody;
const parsedBody = decisionRequestSchema.safeParse(rawBody);

Expand All @@ -101,10 +112,7 @@ export async function POST(request: NextRequest) {
route: "/api/decision",
startedAtMs,
status: 400,
body: {
error: "Invalid decision request body.",
details: parsedBody.error.flatten(),
},
body: formatValidationErrorResponse(parsedBody.error, "Invalid decision request body."),
headers: rateLimitHeaders(rate),
});
}
Expand All @@ -124,7 +132,11 @@ export async function POST(request: NextRequest) {
route: "/api/decision",
startedAtMs,
status: 400,
body: { error: "No action provided." },
body: {
error: "No action provided.",
code: VALIDATION_ERROR_CODES.MISSING_FIELD,
details: { formErrors: ["No action provided."], fieldErrors: { action: ["Required"] } },
},
headers: rateLimitHeaders(rate),
});
}
Expand Down
16 changes: 13 additions & 3 deletions src/app/api/policy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { DuplicateRuleError, validateNoDuplicateRules } from "@/lib/policy/engin
import { parsePolicyImport, policyImportMatchesActive } from "@/lib/policy/import-export";
import { getPolicyConfig, PolicyVersionConflict, updatePolicyConfig } from "@/lib/storage/policy-store";
import { policyConfigSchema } from "@/lib/validation/schemas";
import { logValidationFailure, toPublicValidationDetails } from "@/lib/validation/errors";
import { formatValidationErrorResponse, logValidationFailure, toPublicValidationDetails } from "@/lib/validation/errors";
import { hasSensitiveField } from "@/lib/settings/save-safety";
import { getStellarNetworkFingerprint, resolveStellarNetworkConfig } from "@/lib/stellar/network-config";

Expand Down Expand Up @@ -151,6 +151,16 @@ export async function POST(request: NextRequest) {
const updated = await updatePolicyConfig(imported.document.policy, auth.session.userId, {
expectedVersion: imported.document.version,
});
return jsonWithRequestContext(request, {
route: "/api/policy",
startedAtMs,
status: 200,
body: updated,
headers: rateLimitHeaders(rate),
});
}

if (hasSensitiveField(bodyResult.data)) {
return jsonWithRequestContext(request, {
route: "/api/policy",
startedAtMs,
Expand Down Expand Up @@ -185,7 +195,7 @@ export async function POST(request: NextRequest) {
route: "/api/policy",
startedAtMs,
status: 400,
body: { error: "Invalid policy payload.", details: toPublicValidationDetails(parsed.error) },
body: formatValidationErrorResponse(parsed.error, "Invalid policy payload."),
headers: rateLimitHeaders(rate),
});
}
Expand Down Expand Up @@ -229,7 +239,7 @@ export async function POST(request: NextRequest) {
route: "/api/policy",
startedAtMs,
status: 400,
body: { error: "Invalid policy payload.", details: toPublicValidationDetails(versionMeta.error) },
body: formatValidationErrorResponse(versionMeta.error, "Invalid policy payload."),
headers: rateLimitHeaders(rate),
});
}
Expand Down
26 changes: 15 additions & 11 deletions src/app/api/stellar/build-payment/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,21 @@ vi.mock("@stellar/stellar-sdk", async () => {
};
});

vi.mock("@/lib/decision/engine", () => ({
evaluateDecision: vi.fn(async () => ({
decision: "APPROVE",
explanation: "Fixture decision",
triggeredPolicies: [],
riskScore: 0,
riskFindings: [],
requiresManualApproval: false,
analyzerStatus: { isDegraded: false },
})),
}));
vi.mock("@/lib/decision/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/decision/engine")>();
return {
...actual,
evaluateDecision: vi.fn(async () => ({
decision: "APPROVE",
explanation: "Fixture decision",
triggeredPolicies: [],
riskScore: 0,
riskFindings: [],
requiresManualApproval: false,
analyzerStatus: { isDegraded: false },
})),
};
});

import { Account } from "@stellar/stellar-sdk";
import { NextRequest } from "next/server";
Expand Down
15 changes: 9 additions & 6 deletions src/app/api/stellar/build-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ 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 { enforceRequestGate, gateErrorHeaders } from "@/lib/security/enforcement-gate";
import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit";
import { securityHeadersForRequest } from "@/lib/security/headers";
import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client";
import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote";
import { getQuoteExpiresAt, verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote";
import { createBuildAuthorization, getTransactionHash } from "@/lib/stellar/payment-build-authorization";
import { paymentExceedsPerTxCap } from "@/lib/policy/engine";
import { getAuditEntryById } from "@/lib/storage/audit-store";
import { getPolicyConfig } from "@/lib/storage/policy-store";
import { getUserWallet } from "@/lib/storage/user-wallet-store";
import { stellarBuildPaymentRequestSchema } from "@/lib/validation/schemas";
import {
formatValidationErrorResponse,
logValidationFailure,
toPublicValidationDetails,
} from "@/lib/validation/errors";
Expand All @@ -39,7 +42,7 @@ export async function POST(request: NextRequest) {
if (!gate.ok) {
return NextResponse.json(
{ error: "Rate limit exceeded for payment build endpoint." },
{ status: 429, headers: { ...rateLimitHeaders(rate), ...securityHeadersForRequest(request) } },
{ status: 429, headers: { ...gateErrorHeaders(gate), ...securityHeadersForRequest(request) } },
);
}

Expand Down Expand Up @@ -102,10 +105,10 @@ export async function POST(request: NextRequest) {
bodyResult.data,
);
return NextResponse.json(
{
error: "Invalid payment build request.",
details: parsedPayload.error.flatten(),
},
formatValidationErrorResponse(
parsedPayload.error,
"Invalid payment build request.",
),
{ status: 400, headers: { ...rateLimitHeaders(rate), ...securityHeadersForRequest(request) } },
);
}
Expand Down
31 changes: 28 additions & 3 deletions src/app/api/stellar/submit-signed/route.redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,19 @@ vi.mock("@/lib/readiness/production", () => ({
}));

vi.mock("@/lib/http/read-json-body", () => ({
readJsonBody: vi.fn(async () => ({ ok: true as const, data: { signedXdr: "A".repeat(40) } })),
readJsonBody: vi.fn(async () => ({
ok: true as const,
data: {
signedXdr: "A".repeat(40),
decisionReceipt: {
destination: TEST_DESTINATION,
amountXLM: "1",
asset: "native",
memo: "approved-memo",
network: "testnet",
},
},
})),
}));

vi.mock("@/lib/storage/submit-idempotency-store", () => ({
Expand All @@ -49,7 +61,16 @@ function submitRequest() {
return new NextRequest("http://localhost/api/stellar/submit-signed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ signedXdr: "A".repeat(40) }),
body: JSON.stringify({
signedXdr: "A".repeat(40),
decisionReceipt: {
destination: TEST_DESTINATION,
amountXLM: "1",
asset: "native",
memo: "approved-memo",
network: "testnet",
},
}),
});
}

Expand All @@ -58,7 +79,11 @@ describe("/api/stellar/submit-signed observability redaction", () => {
consoleErrorSpy.mockClear();
vi.mocked(requireAuth).mockReturnValue({
ok: true,
session: { userId: "submit-redaction-user" },
session: {
userId: "submit-redaction-user",
roles: ["signer"],
publicKey: TEST_DESTINATION,
},
} as ReturnType<typeof requireAuth>);
resetMetrics();
});
Expand Down
14 changes: 11 additions & 3 deletions src/app/api/stellar/submit-signed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ declare global {
var MOCK_SESSION_PUBLIC_KEY: string | undefined;
}

import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
import { Account, Asset, Keypair, Memo, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -64,9 +64,10 @@ vi.mock("@/lib/storage/user-wallet-store", () => ({
getUserWallet: vi.fn(),
}));

vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn(async () => ({ id: "decision-1" })) }));
vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: getAuditEntryMock }));
vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/stellar/verify-payment-quote")>(),
verifyPaymentAgainstQuote: verifyQuoteMock,
isPaymentDecisionCurrent: vi.fn(() => true),
}));
vi.mock("@/lib/stellar/payment-build-authorization", async (importOriginal) => ({
Expand Down Expand Up @@ -161,6 +162,11 @@ function makeDecisionReceipt(

beforeEach(() => {
vi.clearAllMocks();
getAuditEntryMock.mockResolvedValue({
id: "00000000-0000-4000-8000-000000000000",
paymentQuote: { memo: "approved-memo" },
});
verifyQuoteMock.mockReturnValue({ ok: true, quote: {} });
vi.mocked(requireAuth).mockImplementation(() => ({
ok: true,
session: { userId: "user-1", publicKey: globalThis.MOCK_SESSION_PUBLIC_KEY || "GCDEFAULTTESTWALLET123" },
Expand All @@ -171,7 +177,9 @@ 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();
globalThis.MOCK_SESSION_PUBLIC_KEY = walletKp.publicKey();
const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey());
const destination = Keypair.random().publicKey();
const signedXdr = buildSignedXdr(walletKp, walletKp.publicKey(), destination, "1", "approved-memo");
const decisionReceipt = makeDecisionReceipt(destination, "1", "approved-memo");

vi.mocked(getUserWallet).mockResolvedValue({
userId: "user-1",
Expand Down
Loading