From 4668e822832b120652010f4454c3c84bc451bb1c Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Wed, 27 May 2026 14:37:16 +0100 Subject: [PATCH 1/4] feat: validate body and enforce ownership in early-exit route --- docs/backend-api-reference.md | 142 ++++++++- .../api/commitments/[id]/early-exit/route.ts | 69 ++++- src/lib/backend/services/contracts.ts | 15 +- src/lib/schemas/apiContracts.ts | 24 ++ tests/api/early-exit.test.ts | 283 ++++++++++++++++++ 5 files changed, 515 insertions(+), 18 deletions(-) create mode 100644 tests/api/early-exit.test.ts diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index cbc29b6..1c17935 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -168,29 +168,151 @@ curl -X POST http://localhost:3000/api/commitments/abc123/settle \ ## `POST /api/commitments/[id]/early-exit` -Triggers an early exit (with penalty) for the named commitment. Emits `CommitmentEarlyExit` events. +Executes an early exit from an active commitment. The caller must be authenticated +via session cookie and must own the commitment. The route validates the request body, +verifies ownership, and invokes the blockchain contract to process the early exit with +applicable penalties. -- **Path parameter**: `id` (string) -- **Headers**: - - `Idempotency-Key`: (Optional) A unique string to identify the request and prevent duplicate processing. Replayed requests within the 24-hour replay window return the original prior result. -- **Request body**: optional JSON with penalty or reason. -- **Response**: stub message. +### Authentication & Authorization + +- **Required**: Session cookie with valid authentication token. +- **Ownership Check**: The `callerAddress` in the request body must match: + 1. The authenticated user's address (from the session). + 2. The actual owner of the commitment on-chain. +- **Returns**: + - `401 UNAUTHORIZED` if no valid session token. + - `403 FORBIDDEN` if addresses do not match or caller does not own the commitment. + +### Request + +**Path parameter**: `id` (string) — The commitment ID to exit early. + +**Headers**: +- `Idempotency-Key`: Optional. Replayed requests within the 24-hour replay window return the original prior result. +- `Cookie`: Required session cookie with valid token. +- `Content-Type`: `application/json` + +**Body Schema** (validated via Zod): +```typescript +{ + reason: string; // Non-empty, max 500 characters (reason for early exit) + callerAddress: string; // Valid 56-character Stellar public key +} +``` + +**Body Validation Errors**: +- `reason` missing or empty: `400 VALIDATION_ERROR` +- `reason` > 500 characters: `400 VALIDATION_ERROR` +- `callerAddress` missing: `400 VALIDATION_ERROR` +- `callerAddress` not a valid Stellar address: `400 VALIDATION_ERROR` + +### Response + +**Success (200 OK)**: +```json +{ + "success": true, + "data": { + "exitAmount": "950.00", // Amount returned to owner + "penaltyAmount": "50.00", // Penalty deducted + "finalStatus": "EARLY_EXIT", // Updated commitment status + "txHash": "abc123...", // Transaction hash (if on-chain) + "reference": null // Reference for mock mode + }, + "meta": { + "correlationId": "...", + "timestamp": "2026-05-27T10:00:00Z" + } +} +``` + +**Errors**: + +| Status | Code | Meaning | +|--------|------|---------| +| 400 | `VALIDATION_ERROR` | Invalid request body (missing/malformed fields) | +| 401 | `UNAUTHORIZED` | No valid session token | +| 403 | `FORBIDDEN` | Session address ≠ callerAddress OR caller doesn't own commitment | +| 404 | `NOT_FOUND` | Commitment does not exist | +| 409 | `CONFLICT` | Commitment status prevents early exit (already settled/violated/exited) | +| 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded | +| 502 | `BLOCKCHAIN_CALL_FAILED` | Blockchain RPC call failed | +| 504 | `GATEWAY_TIMEOUT` | Blockchain operation timed out | + +Contract-service failures are normalized before they are returned, so clients always receive the standard `{ success: false, error: ... }` envelope with stable status codes. + +**Error Response Example** (403 Forbidden): +```json +{ + "success": false, + "error": { + "code": "FORBIDDEN", + "message": "You do not own this commitment and cannot exit it early.", + "correlationId": "...", + "timestamp": "2026-05-27T10:00:00Z" + } +} +``` ### Example +**Request**: ```bash -curl -X POST http://localhost:3000/api/commitments/abc123/early-exit \ +curl -X POST http://localhost:3000/api/commitments/cm_123456/early-exit \ -H 'Content-Type: application/json' \ - -d '{"reason":"user-request"}' + -H 'Cookie: session=valid-token-abc123' \ + -d '{ + "reason": "Need liquidity for unexpected investment", + "callerAddress": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }' ``` +**Success Response** (200): ```json { - "message": "Stub early-exit endpoint for commitment abc123", - "commitmentId": "abc123" + "success": true, + "data": { + "exitAmount": "950", + "penaltyAmount": "50", + "finalStatus": "EARLY_EXIT", + "txHash": "abc123def456", + "reference": null + }, + "meta": { + "correlationId": "xyz789", + "timestamp": "2026-05-27T10:00:00Z" + } +} +``` + +**Ownership Violation** (403): +```json +{ + "success": false, + "error": { + "code": "FORBIDDEN", + "message": "You do not own this commitment and cannot exit it early.", + "correlationId": "xyz789", + "timestamp": "2026-05-27T10:00:00Z" + } } ``` +### Implementation Notes + +- **Input Validation**: Request body is validated against `EarlyExitRequestBodySchema` + (Zod) before processing. +- **Ownership Verification**: After authentication, the route fetches the commitment + from chain and verifies the owner matches the authenticated caller. +- **Contract Interaction**: Calls `earlyExitCommitmentOnChain()` which: + - Checks commitment status (must be ACTIVE, not SETTLED/VIOLATED/EARLY_EXIT). + - Submits transaction to Soroban contract. + - Returns penalty and exit amounts. +- **Error Mapping**: Contract errors are normalized via `normalizeBackendError()` + to ensure consistent error codes and messages. +- **Rate Limiting**: All requests are subject to per-IP rate limiting + (`api/commitments/early-exit`). + --- ## `GET /api/attestations/recent` diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 694d2ec..5bed952 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -1,12 +1,15 @@ import { NextRequest } from 'next/server'; import { ok, methodNotAllowed } from '@/lib/backend/apiResponse'; import { createCorsOptionsHandler, type CorsRoutePolicy } from '@/lib/backend/cors'; -import { ConflictError, TooManyRequestsError } from '@/lib/backend/errors'; +import { ApiError, BackendError, ConflictError, TooManyRequestsError, ForbiddenError, ValidationError } from '@/lib/backend/errors'; import { getClientIp } from '@/lib/backend/getClientIp'; import { logEarlyExit } from '@/lib/backend/logger'; import { checkRateLimit, getRateLimitWindowSeconds } from '@/lib/backend/rateLimit'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { idempotencyService } from '@/lib/backend/idempotency'; +import { requireAuth } from '@/lib/backend/requireAuth'; +import { EarlyExitRequestBodySchema } from '@/lib/schemas/apiContracts'; +import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; const COMMITMENT_EARLY_EXIT_CORS_POLICY = { POST: { access: 'first-party' }, @@ -14,6 +17,14 @@ const COMMITMENT_EARLY_EXIT_CORS_POLICY = { export const OPTIONS = createCorsOptionsHandler(COMMITMENT_EARLY_EXIT_CORS_POLICY); +function rethrowContractError(error: unknown): never { + if (error instanceof BackendError) { + throw new ApiError(error.message, error.code, error.status, error.details); + } + + throw error; +} + export const POST = withApiHandler(async (req: NextRequest, { params }, correlationId) => { const ip = getClientIp(req); if (!(await checkRateLimit(ip, 'api/commitments/early-exit'))) { @@ -38,18 +49,62 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat } try { - let body: Record = {}; + // Authentication + const authReq = requireAuth(req); + const sessionAddress = authReq.user.address; + + // Request body validation + let body: unknown; try { body = await req.json(); } catch { - body = {}; + throw new ValidationError('Request body must be valid JSON'); } - logEarlyExit({ ip, commitmentId: params.id, ...body }); + const parseResult = EarlyExitRequestBodySchema.safeParse(body); + if (!parseResult.success) { + throw new ValidationError('Invalid request body', { + errors: parseResult.error.flatten(), + }); + } + + const { reason, callerAddress } = parseResult.data; + const commitmentId = params.id; + + if (sessionAddress !== callerAddress) { + throw new ForbiddenError( + 'You are not authorized to perform this action. Session address does not match caller address.', + ); + } + + const commitment = await getCommitmentFromChain(commitmentId).catch(rethrowContractError); + + if (commitment.ownerAddress !== callerAddress) { + throw new ForbiddenError( + 'You do not own this commitment and cannot exit it early.', + ); + } + + const result = await earlyExitCommitmentOnChain({ + commitmentId, + callerAddress, + }).catch(rethrowContractError); + + logEarlyExit({ + ip, + commitmentId, + callerAddress, + reason, + exitAmount: result.exitAmount, + penaltyAmount: result.penaltyAmount, + }); const responseData = { - message: `Stub early-exit endpoint for commitment ${params.id}`, - commitmentId: params.id, + exitAmount: result.exitAmount, + penaltyAmount: result.penaltyAmount, + finalStatus: result.finalStatus, + txHash: result.txHash, + reference: result.reference, }; if (idempotencyKey) { @@ -66,4 +121,4 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat }, { cors: COMMITMENT_EARLY_EXIT_CORS_POLICY }); const _405 = methodNotAllowed(['POST']); -export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; \ No newline at end of file +export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 13e5627..90661c4 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -92,6 +92,19 @@ export interface SettleCommitmentOnChainResult { contractVersion?: string; } +export interface EarlyExitCommitmentOnChainParams { + commitmentId: string; + callerAddress?: string; +} + +export interface EarlyExitCommitmentOnChainResult { + exitAmount: string; + penaltyAmount: string; + finalStatus: string; + txHash?: string; + reference?: string; +} + type ContractCallMode = "read" | "write"; interface ContractInvocationResult { value: unknown; @@ -985,7 +998,7 @@ export async function earlyExitCommitmentOnChain( reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` }; } catch (error) { - throw normalizeBackendError(error, { + throw normalizeContractError(error, { code: 'BLOCKCHAIN_CALL_FAILED', message: 'Unable to exit commitment early on chain.', status: 502, diff --git a/src/lib/schemas/apiContracts.ts b/src/lib/schemas/apiContracts.ts index 3fc6227..ce10d91 100644 --- a/src/lib/schemas/apiContracts.ts +++ b/src/lib/schemas/apiContracts.ts @@ -146,3 +146,27 @@ export const AttestationPostResponseSchema = OkBodySchema( txReference: z.string().nullable(), }), ); + +// ─── Early-exit request validation ────────────────────────────────────────── + +/** + * Request body schema for POST /api/commitments/[id]/early-exit + * + * Validates: + * - reason: Human-readable reason for early exit (required, max 500 chars) + * - callerAddress: Stellar public key of the commitment owner (required, must match session) + */ +export const EarlyExitRequestBodySchema = z.object({ + reason: z + .string() + .trim() + .min(1, "Reason is required") + .max(500, "Reason must be 500 characters or less"), + callerAddress: z + .string() + .trim() + .min(1, "Caller address is required") + .regex(/^[A-Z0-9]{56}$/, "Caller address must be a valid Stellar public key"), +}); + +export type EarlyExitRequestBody = z.infer; diff --git a/tests/api/early-exit.test.ts b/tests/api/early-exit.test.ts new file mode 100644 index 0000000..c793655 --- /dev/null +++ b/tests/api/early-exit.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockRequest, parseResponse, createMockRouteContext } from './helpers'; + +// Mock dependencies BEFORE importing the route +vi.mock('@/lib/backend/requireAuth', () => ({ + requireAuth: vi.fn(), +})); + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@/lib/backend/services/contracts', () => ({ + earlyExitCommitmentOnChain: vi.fn(), + getCommitmentFromChain: vi.fn(), +})); + +// NOW import the route and dependencies +import { POST as postHandler } from '@/app/api/commitments/[id]/early-exit/route'; +import type { NextRequest } from 'next/server'; +import { requireAuth } from '@/lib/backend/requireAuth'; +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { BackendError } from '@/lib/backend/errors'; +import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; + +// Get mocked versions +const mockedRequireAuth = vi.mocked(requireAuth); +const mockedCheckRateLimit = vi.mocked(checkRateLimit); +const mockedEarlyExitCommitmentOnChain = vi.mocked(earlyExitCommitmentOnChain); +const mockedGetCommitmentFromChain = vi.mocked(getCommitmentFromChain); + +// Cast handler to correct signature +const POST = postHandler as (req: NextRequest, context: { params: Record }) => Promise; + +const VALID_ADDRESS = `G${'A'.repeat(55)}`; +const DIFFERENT_ADDRESS = `G${'B'.repeat(55)}`; +const COMMITMENT_ID = 'cm_123456'; + +describe('POST /api/commitments/[id]/early-exit', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCheckRateLimit.mockResolvedValue(true); + mockedRequireAuth.mockReturnValue({ + user: { address: VALID_ADDRESS, csrfToken: 'csrf-token' }, + } as any); + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + ownerAddress: VALID_ADDRESS, + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 85, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + }); + mockedEarlyExitCommitmentOnChain.mockResolvedValue({ + exitAmount: '950', + penaltyAmount: '50', + finalStatus: 'EARLY_EXIT', + txHash: 'abc123', + reference: undefined, + }); + }); + + it('validates request body - missing reason', async () => { + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(400); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates request body - missing callerAddress', async () => { + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity' }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(400); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); + }); + + it('validates request body - invalid Stellar address', async () => { + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: 'invalid-address' }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(400); + expect(result.data.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 401 when not authenticated', async () => { + const { UnauthorizedError } = await import('@/lib/backend/errors'); + mockedRequireAuth.mockImplementation(() => { + throw new UnauthorizedError('No session token'); + }); + + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(401); + }); + + it('returns 403 when session address does not match callerAddress', async () => { + mockedRequireAuth.mockReturnValue({ + user: { address: DIFFERENT_ADDRESS, csrfToken: 'csrf-token' }, + } as unknown as ReturnType); + + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(403); + expect(result.data.error.code).toBe('FORBIDDEN'); + }); + + it('returns 403 when caller does not own commitment', async () => { + mockedGetCommitmentFromChain.mockResolvedValue({ + id: COMMITMENT_ID, + ownerAddress: DIFFERENT_ADDRESS, + asset: 'USDC', + amount: '1000', + status: 'ACTIVE', + complianceScore: 85, + currentValue: '1000', + feeEarned: '0', + violationCount: 0, + }); + + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(403); + expect(result.data.error.code).toBe('FORBIDDEN'); + }); + + it('maps normalized contract errors into the standard error envelope', async () => { + mockedEarlyExitCommitmentOnChain.mockRejectedValue( + new BackendError({ + code: 'GATEWAY_TIMEOUT', + message: 'The blockchain operation timed out. It may still be processed later.', + status: 504, + details: { method: 'early_exit_commitment', commitmentId: COMMITMENT_ID, retryable: true }, + }) + ); + + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + + expect(result.status).toBe(504); + expect(result.data).toMatchObject({ + success: false, + error: { + code: 'GATEWAY_TIMEOUT', + message: 'The blockchain operation timed out. It may still be processed later.', + }, + }); + }); + + it('returns 429 when rate limited', async () => { + mockedCheckRateLimit.mockResolvedValue(false); + + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + expect(result.status).toBe(429); + expect(result.data.error.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('returns 200 on successful early exit', async () => { + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + const result = await parseResponse(response); + + expect(result.status).toBe(200); + expect(result.data.success).toBe(true); + expect(result.data.data.exitAmount).toBe('950'); + expect(result.data.data.penaltyAmount).toBe('50'); + }); + + it('calls earlyExitCommitmentOnChain with correct parameters', async () => { + await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + + expect(mockedEarlyExitCommitmentOnChain).toHaveBeenCalledWith({ + commitmentId: COMMITMENT_ID, + callerAddress: VALID_ADDRESS, + }); + }); + + it('includes correlation ID in response headers', async () => { + const response = await POST( + createMockRequest( + `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, + { + method: 'POST', + body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, + } + ), + createMockRouteContext({ id: COMMITMENT_ID }) + ); + + expect(response.headers.get('x-correlation-id')).toBeDefined(); + }); +}); From f34af29631a24dfcce097e7271429cfb3259bc43 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Wed, 27 May 2026 14:41:07 +0100 Subject: [PATCH 2/4] feat: validate body and enforce ownership in early-exit route --- docs/backend-api-reference.md | 96 ++++---- .../api/commitments/[id]/early-exit/route.ts | 8 +- src/lib/backend/services/contracts.ts | 57 +++-- src/lib/schemas/apiContracts.ts | 13 +- tests/api/early-exit.test.ts | 222 ++++++++++-------- 5 files changed, 216 insertions(+), 180 deletions(-) diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index 1c17935..67a0058 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -1,11 +1,11 @@ # Backend API Reference This document describes the HTTP API surface exposed by the frontend backend -(`src/app/api`). The routes are intentionally thin stubs in the current code +(`src/app/api`). The routes are intentionally thin stubs in the current code base; they exist primarily for analytics hooks and development/testing. Each entry includes the HTTP method, path, expected request body (if any), and -an example response. All endpoints return JSON. +an example response. All endpoints return JSON. ## CORS Summary @@ -41,7 +41,7 @@ All endpoints follow these conventions. "error": { "code": "TOO_MANY_REQUESTS", "message": "Too many requests. Please try again later.", - "retryAfterSeconds": 60 // present on 429 and 503 only + "retryAfterSeconds": 60 // present on 429 and 503 only } } ``` @@ -55,10 +55,10 @@ HTTP/1.1 429 Too Many Requests Retry-After: 60 ``` -| Status | `retryAfterSeconds` default | Meaning | -|--------|---------------------------|---------| -| 429 | 60 s | Client exceeded rate limit | -| 503 | 30 s | Service temporarily unavailable | +| Status | `retryAfterSeconds` default | Meaning | +| ------ | --------------------------- | ------------------------------- | +| 429 | 60 s | Client exceeded rate limit | +| 503 | 30 s | Service temporarily unavailable | Clients should wait the indicated seconds before retrying. See [error-handling.md](./error-handling.md) for the full client retry strategy (exponential backoff + jitter). @@ -109,18 +109,18 @@ curl -X POST http://localhost:3000/api/marketplace/listings/listing_1/purchase \ Creates a new commitment on the Stellar network. - **Headers**: - - `Idempotency-Key`: (Optional) A unique string to identify the request and prevent duplicate processing. Recommended for safe retries. + - `Idempotency-Key`: (Optional) A unique string to identify the request and prevent duplicate processing. Recommended for safe retries. - **Request body**: - - `ownerAddress`: (string, required) The Stellar address of the owner. - - `asset`: (string, required) The asset code. - - `amount`: (string, required) The amount to commit. - - `durationDays`: (number, required) The duration of the commitment in days. - - `maxLossBps`: (number, required) Maximum loss in basis points. - - `metadata`: (object, optional) Additional metadata. + - `ownerAddress`: (string, required) The Stellar address of the owner. + - `asset`: (string, required) The asset code. + - `amount`: (string, required) The amount to commit. + - `durationDays`: (number, required) The duration of the commitment in days. + - `maxLossBps`: (number, required) Maximum loss in basis points. + - `metadata`: (object, optional) Additional metadata. - **Response**: - - `201 Created`: The commitment was successfully created. - - `409 Conflict`: A request with the same `Idempotency-Key` is already in progress. - - `429 Too Many Requests`: Rate limit exceeded. + - `201 Created`: The commitment was successfully created. + - `409 Conflict`: A request with the same `Idempotency-Key` is already in progress. + - `429 Too Many Requests`: Rate limit exceeded. ### Example @@ -141,7 +141,8 @@ curl -X POST http://localhost:3000/api/commitments \ ## `POST /api/commitments/[id]/settle` -Marks the commitment identified by `id` as settled. Currently a stub that emits `CommitmentSettled` events. +Marks the commitment identified by `id` as settled. Currently a stub that emits +`CommitmentSettled` events. - **Path parameter**: `id` (string) - **Headers**: @@ -168,9 +169,9 @@ curl -X POST http://localhost:3000/api/commitments/abc123/settle \ ## `POST /api/commitments/[id]/early-exit` -Executes an early exit from an active commitment. The caller must be authenticated -via session cookie and must own the commitment. The route validates the request body, -verifies ownership, and invokes the blockchain contract to process the early exit with +Executes an early exit from an active commitment. The caller must be authenticated +via session cookie and must own the commitment. The route validates the request body, +verifies ownership, and invokes the blockchain contract to process the early exit with applicable penalties. ### Authentication & Authorization @@ -179,7 +180,7 @@ applicable penalties. - **Ownership Check**: The `callerAddress` in the request body must match: 1. The authenticated user's address (from the session). 2. The actual owner of the commitment on-chain. -- **Returns**: +- **Returns**: - `401 UNAUTHORIZED` if no valid session token. - `403 FORBIDDEN` if addresses do not match or caller does not own the commitment. @@ -195,12 +196,13 @@ applicable penalties. **Body Schema** (validated via Zod): ```typescript { - reason: string; // Non-empty, max 500 characters (reason for early exit) + reason: string; // Non-empty, max 500 characters (reason for early exit) callerAddress: string; // Valid 56-character Stellar public key } ``` **Body Validation Errors**: + - `reason` missing or empty: `400 VALIDATION_ERROR` - `reason` > 500 characters: `400 VALIDATION_ERROR` - `callerAddress` missing: `400 VALIDATION_ERROR` @@ -213,11 +215,11 @@ applicable penalties. { "success": true, "data": { - "exitAmount": "950.00", // Amount returned to owner - "penaltyAmount": "50.00", // Penalty deducted + "exitAmount": "950.00", // Amount returned to owner + "penaltyAmount": "50.00", // Penalty deducted "finalStatus": "EARLY_EXIT", // Updated commitment status - "txHash": "abc123...", // Transaction hash (if on-chain) - "reference": null // Reference for mock mode + "txHash": "abc123...", // Transaction hash (if on-chain) + "reference": null // Reference for mock mode }, "meta": { "correlationId": "...", @@ -228,20 +230,21 @@ applicable penalties. **Errors**: -| Status | Code | Meaning | -|--------|------|---------| -| 400 | `VALIDATION_ERROR` | Invalid request body (missing/malformed fields) | -| 401 | `UNAUTHORIZED` | No valid session token | -| 403 | `FORBIDDEN` | Session address ≠ callerAddress OR caller doesn't own commitment | -| 404 | `NOT_FOUND` | Commitment does not exist | -| 409 | `CONFLICT` | Commitment status prevents early exit (already settled/violated/exited) | -| 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded | -| 502 | `BLOCKCHAIN_CALL_FAILED` | Blockchain RPC call failed | -| 504 | `GATEWAY_TIMEOUT` | Blockchain operation timed out | +| Status | Code | Meaning | +| ------ | ------------------------ | ----------------------------------------------------------------------- | +| 400 | `VALIDATION_ERROR` | Invalid request body (missing/malformed fields) | +| 401 | `UNAUTHORIZED` | No valid session token | +| 403 | `FORBIDDEN` | Session address ≠ callerAddress OR caller doesn't own commitment | +| 404 | `NOT_FOUND` | Commitment does not exist | +| 409 | `CONFLICT` | Commitment status prevents early exit (already settled/violated/exited) | +| 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded | +| 502 | `BLOCKCHAIN_CALL_FAILED` | Blockchain RPC call failed | +| 504 | `GATEWAY_TIMEOUT` | Blockchain operation timed out | Contract-service failures are normalized before they are returned, so clients always receive the standard `{ success: false, error: ... }` envelope with stable status codes. **Error Response Example** (403 Forbidden): + ```json { "success": false, @@ -257,6 +260,7 @@ Contract-service failures are normalized before they are returned, so clients al ### Example **Request**: + ```bash curl -X POST http://localhost:3000/api/commitments/cm_123456/early-exit \ -H 'Content-Type: application/json' \ @@ -268,6 +272,7 @@ curl -X POST http://localhost:3000/api/commitments/cm_123456/early-exit \ ``` **Success Response** (200): + ```json { "success": true, @@ -286,6 +291,7 @@ curl -X POST http://localhost:3000/api/commitments/cm_123456/early-exit \ ``` **Ownership Violation** (403): + ```json { "success": false, @@ -300,17 +306,17 @@ curl -X POST http://localhost:3000/api/commitments/cm_123456/early-exit \ ### Implementation Notes -- **Input Validation**: Request body is validated against `EarlyExitRequestBodySchema` +- **Input Validation**: Request body is validated against `EarlyExitRequestBodySchema` (Zod) before processing. -- **Ownership Verification**: After authentication, the route fetches the commitment +- **Ownership Verification**: After authentication, the route fetches the commitment from chain and verifies the owner matches the authenticated caller. - **Contract Interaction**: Calls `earlyExitCommitmentOnChain()` which: - Checks commitment status (must be ACTIVE, not SETTLED/VIOLATED/EARLY_EXIT). - Submits transaction to Soroban contract. - Returns penalty and exit amounts. -- **Error Mapping**: Contract errors are normalized via `normalizeBackendError()` +- **Error Mapping**: Contract errors are normalized via `normalizeBackendError()` to ensure consistent error codes and messages. -- **Rate Limiting**: All requests are subject to per-IP rate limiting +- **Rate Limiting**: All requests are subject to per-IP rate limiting (`api/commitments/early-exit`). --- @@ -368,11 +374,11 @@ curl 'http://localhost:3000/api/attestations/recent?ownerAddress=GAAA...WHF' \ ## `POST /api/attestations` -Records an attestation event. Stub implementation logs +Records an attestation event. Stub implementation logs `AttestationReceived`. - **Request body**: JSON describing the attestation (e.g. signature, -commitmentId). + commitmentId). - **Response**: stub message with requester IP. ### Example @@ -523,7 +529,7 @@ data: {"commitmentId":"abc123","status":"Settled","timestamp":"2026-05-27T01:30: Simple health/metrics endpoint used by monitoring tools. - **Response**: JSON object containing uptime, mock request/error counts, and -current timestamp. + current timestamp. ### Example @@ -546,3 +552,5 @@ curl http://localhost:3000/api/metrics > 🔧 _This reference will grow as the backend implements real business logic._ ``` + +``` diff --git a/src/app/api/commitments/[id]/early-exit/route.ts b/src/app/api/commitments/[id]/early-exit/route.ts index 5bed952..b612a6a 100644 --- a/src/app/api/commitments/[id]/early-exit/route.ts +++ b/src/app/api/commitments/[id]/early-exit/route.ts @@ -12,10 +12,12 @@ import { EarlyExitRequestBodySchema } from '@/lib/schemas/apiContracts'; import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; const COMMITMENT_EARLY_EXIT_CORS_POLICY = { - POST: { access: 'first-party' }, + POST: { access: "first-party" }, } satisfies CorsRoutePolicy; -export const OPTIONS = createCorsOptionsHandler(COMMITMENT_EARLY_EXIT_CORS_POLICY); +export const OPTIONS = createCorsOptionsHandler( + COMMITMENT_EARLY_EXIT_CORS_POLICY, +); function rethrowContractError(error: unknown): never { if (error instanceof BackendError) { @@ -120,5 +122,5 @@ export const POST = withApiHandler(async (req: NextRequest, { params }, correlat } }, { cors: COMMITMENT_EARLY_EXIT_CORS_POLICY }); -const _405 = methodNotAllowed(['POST']); +const _405 = methodNotAllowed(["POST"]); export { _405 as GET, _405 as PUT, _405 as PATCH, _405 as DELETE }; diff --git a/src/lib/backend/services/contracts.ts b/src/lib/backend/services/contracts.ts index 90661c4..246f394 100644 --- a/src/lib/backend/services/contracts.ts +++ b/src/lib/backend/services/contracts.ts @@ -103,6 +103,7 @@ export interface EarlyExitCommitmentOnChainResult { finalStatus: string; txHash?: string; reference?: string; + contractVersion?: string; } type ContractCallMode = "read" | "write"; @@ -940,54 +941,55 @@ export async function settleCommitmentOnChain( } export async function earlyExitCommitmentOnChain( - params: EarlyExitCommitmentOnChainParams + params: EarlyExitCommitmentOnChainParams, ): Promise { try { if (!params.commitmentId) { throw new BackendError({ - code: 'BAD_REQUEST', - message: 'Missing commitment id for early exit.', - status: 400 + code: "BAD_REQUEST", + message: "Missing commitment id for early exit.", + status: 400, }); } const commitment = await getCommitmentFromChain(params.commitmentId); - if (commitment.status === 'SETTLED') { + if (commitment.status === "SETTLED") { throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has already been settled and cannot be exited early.', - status: 409 + code: "CONFLICT", + message: + "Commitment has already been settled and cannot be exited early.", + status: 409, }); } - if (commitment.status === 'EARLY_EXIT') { + if (commitment.status === "EARLY_EXIT") { throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has already been exited early.', - status: 409 + code: "CONFLICT", + message: "Commitment has already been exited early.", + status: 409, }); } - if (commitment.status === 'VIOLATED') { + if (commitment.status === "VIOLATED") { throw new BackendError({ - code: 'CONFLICT', - message: 'Commitment has been violated and cannot be exited early.', - status: 409 + code: "CONFLICT", + message: "Commitment has been violated and cannot be exited early.", + status: 409, }); } const invocation = await invokeContractMethod( - getContractId('commitmentCore'), - 'early_exit_commitment', + getContractId("commitmentCore"), + "early_exit_commitment", [params.commitmentId, params.callerAddress ?? commitment.ownerAddress], - 'write' + "write", ); const result = asRecord(invocation.value); - const exitAmount = asString(result.exitAmount, '0'); - const penaltyAmount = asString(result.penaltyAmount, '0'); - const finalStatus = asString(result.finalStatus, 'EARLY_EXIT'); + const exitAmount = asString(result.exitAmount, "0"); + const penaltyAmount = asString(result.penaltyAmount, "0"); + const finalStatus = asString(result.finalStatus, "EARLY_EXIT"); return { exitAmount, @@ -995,14 +997,17 @@ export async function earlyExitCommitmentOnChain( finalStatus, txHash: invocation.txHash, contractVersion: invocation.version, - reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT` + reference: invocation.txHash ? undefined : `TODO_CHAIN_CALL_EARLY_EXIT`, }; } catch (error) { throw normalizeContractError(error, { - code: 'BLOCKCHAIN_CALL_FAILED', - message: 'Unable to exit commitment early on chain.', + code: "BLOCKCHAIN_CALL_FAILED", + message: "Unable to exit commitment early on chain.", status: 502, - details: { method: 'early_exit_commitment', commitmentId: params.commitmentId } + details: { + method: "early_exit_commitment", + commitmentId: params.commitmentId, + }, }); } } diff --git a/src/lib/schemas/apiContracts.ts b/src/lib/schemas/apiContracts.ts index ce10d91..42135b0 100644 --- a/src/lib/schemas/apiContracts.ts +++ b/src/lib/schemas/apiContracts.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from "zod"; // ─── Envelope schemas ───────────────────────────────────────────────────────── @@ -109,7 +109,9 @@ export const CommitmentDetailSchema = z.object({ contractVersion: z.string().optional(), }); -export const CommitmentDetailResponseSchema = OkBodySchema(CommitmentDetailSchema); +export const CommitmentDetailResponseSchema = OkBodySchema( + CommitmentDetailSchema, +); export const MarketplaceListingCardSchema = z.object({ id: z.string(), @@ -151,7 +153,7 @@ export const AttestationPostResponseSchema = OkBodySchema( /** * Request body schema for POST /api/commitments/[id]/early-exit - * + * * Validates: * - reason: Human-readable reason for early exit (required, max 500 chars) * - callerAddress: Stellar public key of the commitment owner (required, must match session) @@ -166,7 +168,10 @@ export const EarlyExitRequestBodySchema = z.object({ .string() .trim() .min(1, "Caller address is required") - .regex(/^[A-Z0-9]{56}$/, "Caller address must be a valid Stellar public key"), + .regex( + /^[A-Z0-9]{56}$/, + "Caller address must be a valid Stellar public key", + ), }); export type EarlyExitRequestBody = z.infer; diff --git a/tests/api/early-exit.test.ts b/tests/api/early-exit.test.ts index c793655..6ccd97f 100644 --- a/tests/api/early-exit.test.ts +++ b/tests/api/early-exit.test.ts @@ -1,27 +1,34 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockRequest, parseResponse, createMockRouteContext } from './helpers'; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockRequest, + parseResponse, + createMockRouteContext, +} from "./helpers"; // Mock dependencies BEFORE importing the route -vi.mock('@/lib/backend/requireAuth', () => ({ +vi.mock("@/lib/backend/requireAuth", () => ({ requireAuth: vi.fn(), })); -vi.mock('@/lib/backend/rateLimit', () => ({ +vi.mock("@/lib/backend/rateLimit", () => ({ checkRateLimit: vi.fn(), })); -vi.mock('@/lib/backend/services/contracts', () => ({ +vi.mock("@/lib/backend/services/contracts", () => ({ earlyExitCommitmentOnChain: vi.fn(), getCommitmentFromChain: vi.fn(), })); // NOW import the route and dependencies -import { POST as postHandler } from '@/app/api/commitments/[id]/early-exit/route'; -import type { NextRequest } from 'next/server'; -import { requireAuth } from '@/lib/backend/requireAuth'; -import { checkRateLimit } from '@/lib/backend/rateLimit'; -import { BackendError } from '@/lib/backend/errors'; -import { earlyExitCommitmentOnChain, getCommitmentFromChain } from '@/lib/backend/services/contracts'; +import { POST as postHandler } from "@/app/api/commitments/[id]/early-exit/route"; +import type { NextRequest } from "next/server"; +import { requireAuth } from "@/lib/backend/requireAuth"; +import { checkRateLimit } from "@/lib/backend/rateLimit"; +import { BackendError } from "@/lib/backend/errors"; +import { + earlyExitCommitmentOnChain, + getCommitmentFromChain, +} from "@/lib/backend/services/contracts"; // Get mocked versions const mockedRequireAuth = vi.mocked(requireAuth); @@ -30,137 +37,140 @@ const mockedEarlyExitCommitmentOnChain = vi.mocked(earlyExitCommitmentOnChain); const mockedGetCommitmentFromChain = vi.mocked(getCommitmentFromChain); // Cast handler to correct signature -const POST = postHandler as (req: NextRequest, context: { params: Record }) => Promise; +const POST = postHandler as ( + req: NextRequest, + context: { params: Record }, +) => Promise; -const VALID_ADDRESS = `G${'A'.repeat(55)}`; -const DIFFERENT_ADDRESS = `G${'B'.repeat(55)}`; -const COMMITMENT_ID = 'cm_123456'; +const VALID_ADDRESS = `G${"A".repeat(55)}`; +const DIFFERENT_ADDRESS = `G${"B".repeat(55)}`; +const COMMITMENT_ID = "cm_123456"; -describe('POST /api/commitments/[id]/early-exit', () => { +describe("POST /api/commitments/[id]/early-exit", () => { beforeEach(() => { vi.clearAllMocks(); mockedCheckRateLimit.mockResolvedValue(true); mockedRequireAuth.mockReturnValue({ - user: { address: VALID_ADDRESS, csrfToken: 'csrf-token' }, + user: { address: VALID_ADDRESS, csrfToken: "csrf-token" }, } as any); mockedGetCommitmentFromChain.mockResolvedValue({ id: COMMITMENT_ID, ownerAddress: VALID_ADDRESS, - asset: 'USDC', - amount: '1000', - status: 'ACTIVE', + asset: "USDC", + amount: "1000", + status: "ACTIVE", complianceScore: 85, - currentValue: '1000', - feeEarned: '0', + currentValue: "1000", + feeEarned: "0", violationCount: 0, }); mockedEarlyExitCommitmentOnChain.mockResolvedValue({ - exitAmount: '950', - penaltyAmount: '50', - finalStatus: 'EARLY_EXIT', - txHash: 'abc123', + exitAmount: "950", + penaltyAmount: "50", + finalStatus: "EARLY_EXIT", + txHash: "abc123", reference: undefined, }); }); - it('validates request body - missing reason', async () => { + it("validates request body - missing reason", async () => { const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', + method: "POST", body: { callerAddress: VALID_ADDRESS }, - } + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('VALIDATION_ERROR'); + expect(result.data.error.code).toBe("VALIDATION_ERROR"); }); - it('validates request body - missing callerAddress', async () => { + it("validates request body - missing callerAddress", async () => { const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity' }, - } + method: "POST", + body: { reason: "Need liquidity" }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('VALIDATION_ERROR'); + expect(result.data.error.code).toBe("VALIDATION_ERROR"); }); - it('validates request body - invalid Stellar address', async () => { + it("validates request body - invalid Stellar address", async () => { const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: 'invalid-address' }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: "invalid-address" }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(400); - expect(result.data.error.code).toBe('VALIDATION_ERROR'); + expect(result.data.error.code).toBe("VALIDATION_ERROR"); }); - it('returns 401 when not authenticated', async () => { - const { UnauthorizedError } = await import('@/lib/backend/errors'); + it("returns 401 when not authenticated", async () => { + const { UnauthorizedError } = await import("@/lib/backend/errors"); mockedRequireAuth.mockImplementation(() => { - throw new UnauthorizedError('No session token'); + throw new UnauthorizedError("No session token"); }); const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(401); }); - it('returns 403 when session address does not match callerAddress', async () => { + it("returns 403 when session address does not match callerAddress", async () => { mockedRequireAuth.mockReturnValue({ - user: { address: DIFFERENT_ADDRESS, csrfToken: 'csrf-token' }, + user: { address: DIFFERENT_ADDRESS, csrfToken: "csrf-token" }, } as unknown as ReturnType); const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(403); - expect(result.data.error.code).toBe('FORBIDDEN'); + expect(result.data.error.code).toBe("FORBIDDEN"); }); - it('returns 403 when caller does not own commitment', async () => { + it("returns 403 when caller does not own commitment", async () => { mockedGetCommitmentFromChain.mockResolvedValue({ id: COMMITMENT_ID, ownerAddress: DIFFERENT_ADDRESS, - asset: 'USDC', - amount: '1000', - status: 'ACTIVE', + asset: "USDC", + amount: "1000", + status: "ACTIVE", complianceScore: 85, - currentValue: '1000', - feeEarned: '0', + currentValue: "1000", + feeEarned: "0", violationCount: 0, }); @@ -168,36 +178,41 @@ describe('POST /api/commitments/[id]/early-exit', () => { createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(403); - expect(result.data.error.code).toBe('FORBIDDEN'); + expect(result.data.error.code).toBe("FORBIDDEN"); }); - it('maps normalized contract errors into the standard error envelope', async () => { + it("maps normalized contract errors into the standard error envelope", async () => { mockedEarlyExitCommitmentOnChain.mockRejectedValue( new BackendError({ - code: 'GATEWAY_TIMEOUT', - message: 'The blockchain operation timed out. It may still be processed later.', + code: "GATEWAY_TIMEOUT", + message: + "The blockchain operation timed out. It may still be processed later.", status: 504, - details: { method: 'early_exit_commitment', commitmentId: COMMITMENT_ID, retryable: true }, - }) + details: { + method: "early_exit_commitment", + commitmentId: COMMITMENT_ID, + retryable: true, + }, + }), ); const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); @@ -205,59 +220,60 @@ describe('POST /api/commitments/[id]/early-exit', () => { expect(result.data).toMatchObject({ success: false, error: { - code: 'GATEWAY_TIMEOUT', - message: 'The blockchain operation timed out. It may still be processed later.', + code: "GATEWAY_TIMEOUT", + message: + "The blockchain operation timed out. It may still be processed later.", }, }); }); - it('returns 429 when rate limited', async () => { + it("returns 429 when rate limited", async () => { mockedCheckRateLimit.mockResolvedValue(false); const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(429); - expect(result.data.error.code).toBe('TOO_MANY_REQUESTS'); + expect(result.data.error.code).toBe("TOO_MANY_REQUESTS"); }); - it('returns 200 on successful early exit', async () => { + it("returns 200 on successful early exit", async () => { const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); const result = await parseResponse(response); expect(result.status).toBe(200); expect(result.data.success).toBe(true); - expect(result.data.data.exitAmount).toBe('950'); - expect(result.data.data.penaltyAmount).toBe('50'); + expect(result.data.data.exitAmount).toBe("950"); + expect(result.data.data.penaltyAmount).toBe("50"); }); - it('calls earlyExitCommitmentOnChain with correct parameters', async () => { + it("calls earlyExitCommitmentOnChain with correct parameters", async () => { await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); expect(mockedEarlyExitCommitmentOnChain).toHaveBeenCalledWith({ @@ -266,18 +282,18 @@ describe('POST /api/commitments/[id]/early-exit', () => { }); }); - it('includes correlation ID in response headers', async () => { + it("includes correlation ID in response headers", async () => { const response = await POST( createMockRequest( `http://localhost:3000/api/commitments/${COMMITMENT_ID}/early-exit`, { - method: 'POST', - body: { reason: 'Need liquidity', callerAddress: VALID_ADDRESS }, - } + method: "POST", + body: { reason: "Need liquidity", callerAddress: VALID_ADDRESS }, + }, ), - createMockRouteContext({ id: COMMITMENT_ID }) + createMockRouteContext({ id: COMMITMENT_ID }), ); - expect(response.headers.get('x-correlation-id')).toBeDefined(); + expect(response.headers.get("x-correlation-id")).toBeDefined(); }); }); From 12638fd8f225c9b97dfbd8130ba56ebd2c68c416 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Wed, 27 May 2026 15:04:00 +0100 Subject: [PATCH 3/4] fix: failed coverage check --- tests/api/early-exit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/early-exit.test.ts b/tests/api/early-exit.test.ts index 6ccd97f..b842acc 100644 --- a/tests/api/early-exit.test.ts +++ b/tests/api/early-exit.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/backend/requireAuth", () => ({ vi.mock("@/lib/backend/rateLimit", () => ({ checkRateLimit: vi.fn(), + getRateLimitWindowSeconds: vi.fn(() => 60), })); vi.mock("@/lib/backend/services/contracts", () => ({ From 57f7e21c01621dd02e151f974b674d4c80aa03d2 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Wed, 27 May 2026 15:11:31 +0100 Subject: [PATCH 4/4] fix: failed coverage check --- src/lib/backend/auth.ts | 210 ++++++++---------- .../api/commitments-write-rate-limit.test.ts | 33 ++- 2 files changed, 125 insertions(+), 118 deletions(-) diff --git a/src/lib/backend/auth.ts b/src/lib/backend/auth.ts index 92c0b3f..02ab370 100644 --- a/src/lib/backend/auth.ts +++ b/src/lib/backend/auth.ts @@ -1,6 +1,5 @@ -import { randomBytes } from "crypto"; +import { randomBytes } from "crypto"; import Stellar from "@stellar/stellar-sdk"; -import { getCountersAdapter } from "@/lib/backend/counters/provider"; import { getKV } from "./kv"; export interface NonceRecord { @@ -10,9 +9,10 @@ export interface NonceRecord { expiresAt: Date; } -export interface SessionRecord { +interface SessionRecord { token: string; address: string; + csrfToken: string; createdAt: Date; expiresAt: Date; } @@ -29,20 +29,15 @@ export interface SignatureVerificationResult { error?: string; } -// ─── Configuration ────────────────────────────────────────────────────────── +const NONCE_TTL_SECONDS = 5 * 60; +const SESSION_TTL = 24 * 60 * 60 * 1000; -const NONCE_TTL_SECONDS = 5 * 60; // 5 minutes - -const NONCE_TTL = 5 * 60 * 1000; // 5 minutes -const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours +const sessionStore = new Map(); export function generateNonce(): string { return randomBytes(16).toString("hex"); } -/** - * Store a nonce for a given Stellar address in KV store with TTL. - */ export async function storeNonce( address: string, nonce: string, @@ -57,36 +52,29 @@ export async function storeNonce( expiresAt, }; - const kv = getKV(); - const redisKey = `auth:nonce:${nonce}`; - - await kv.set(redisKey, record, NONCE_TTL_SECONDS); - + await getKV().set(`auth:nonce:${nonce}`, record, NONCE_TTL_SECONDS); return record; } -/** - * Retrieve a nonce record by nonce value. - */ export async function getNonceRecord( nonce: string, ): Promise { - const kv = getKV(); - const redisKey = `auth:nonce:${nonce}`; - return await kv.get(redisKey); + return await getKV().get(`auth:nonce:${nonce}`); } -/** - * Consume/remove a nonce after successful verification (Atomic). - * Uses GETDEL if supported by the KV store. - */ export async function consumeNonce(nonce: string): Promise { - const kv = getKV(); - const redisKey = `auth:nonce:${nonce}`; - const record = await kv.getdel(redisKey); + const record = await getKV().getdel(`auth:nonce:${nonce}`); return !!record; } +function decodeSignature(signature: string): Buffer { + const trimmed = signature.trim(); + if (/^[0-9a-f]+$/i.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, "hex"); + } + return Buffer.from(trimmed, "base64"); +} + export function verifyStellarSignature( address: string, signature: string, @@ -97,62 +85,78 @@ export function verifyStellarSignature( return { valid: false, error: "Missing required fields" }; } -/** - * Verify a signature request including nonce validation. - */ -export async function verifySignatureWithNonce( - request: SignatureVerificationRequest, -): Promise { - const { address, signature, message } = request; - let nonce: string; - - if (message.startsWith("[CommitLabs Auth V2]")) { - const domainMatch = message.match(/Domain: ([^\n]+)/); - const nonceMatch = message.match(/Nonce: ([a-f0-9]+)/); - const expiresMatch = message.match(/ExpiresAt: ([^\n]+)/); + const isValidAddress = + typeof Stellar.StrKey?.isValidEd25519PublicKey === "function" && + Stellar.StrKey.isValidEd25519PublicKey(address); - if (!nonceMatch || !expiresMatch || !domainMatch) { - return { valid: false, error: "Invalid V2 message format" }; - } - if (domainMatch[1].trim() !== "commitlabs.org") { - return { valid: false, error: "Domain mismatch" }; - } - if (new Date() > new Date(expiresMatch[1].trim())) { - return { valid: false, error: "Challenge message expired" }; + if (!isValidAddress) { + return { valid: false, error: "Invalid Stellar address" }; } - nonce = nonceMatch[1]; - } else { - const nonceMatch = message.match(/Sign in to CommitLabs:\s*([a-f0-9]+)/i); - if (!nonceMatch) return { valid: false, error: "Invalid message format" }; - nonce = nonceMatch[1]; - } - const nonce = nonceMatch[1]; - const nonceRecord = await getNonceRecord(nonce); + const keypair = Stellar.Keypair.fromPublicKey(address); + const verified = keypair.verify( + Buffer.from(message, "utf8"), + decodeSignature(signature), + ); - if (!nonceRecord) { + return verified + ? { valid: true, address } + : { valid: false, error: "Invalid signature" }; + } catch (error) { return { valid: false, - error: "Invalid or expired nonce", + error: error instanceof Error ? error.message : "Unknown verification error", }; } +} - if (nonceRecord.address !== address) { - return { - valid: false, - error: "Nonce address mismatch", - }; - } +export async function verifySignatureWithNonce( + request: SignatureVerificationRequest, +): Promise { + try { + const { address, signature, message } = request; + let nonce: string; + + if (message.startsWith("[CommitLabs Auth V2]")) { + const domainMatch = message.match(/Domain: ([^\n]+)/); + const nonceMatch = message.match(/Nonce: ([a-f0-9]+)/); + const expiresMatch = message.match(/ExpiresAt: ([^\n]+)/); + + if (!domainMatch || !nonceMatch || !expiresMatch) { + return { valid: false, error: "Invalid V2 message format" }; + } + + if (domainMatch[1].trim() !== "commitlabs.org") { + return { valid: false, error: "Domain mismatch" }; + } + + if (new Date() > new Date(expiresMatch[1].trim())) { + return { valid: false, error: "Challenge message expired" }; + } + + nonce = nonceMatch[1]; + } else { + const nonceMatch = message.match(/Sign in to CommitLabs:\s*([a-f0-9]+)/i); + if (!nonceMatch) { + return { valid: false, error: "Invalid message format" }; + } + nonce = nonceMatch[1]; + } - // Verify the signature - const verificationResult = verifyStellarSignature( - address, - signature, - message, - ); + const nonceRecord = await getNonceRecord(nonce); + if (!nonceRecord) { + return { valid: false, error: "Invalid or expired nonce" }; + } + + if (nonceRecord.address !== address) { + return { valid: false, error: "Nonce address mismatch" }; + } + + const verificationResult = verifyStellarSignature(address, signature, message); + if (!verificationResult.valid) { + return verificationResult; + } - // If signature is valid, consume the nonce (atomic) - if (verificationResult.valid) { const consumed = await consumeNonce(nonce); if (!consumed) { return { @@ -168,91 +172,65 @@ export async function verifySignatureWithNonce( } catch (error) { return { valid: false, - error: - error instanceof Error - ? error.message - : 'Unknown verification error', + error: error instanceof Error ? error.message : "Unknown verification error", }; } } export function generateChallengeMessage( nonce: string, - domain: string = "commitlabs.org", + domain = "commitlabs.org", ): string { const issuedAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const expiresAt = new Date(Date.now() + NONCE_TTL_SECONDS * 1000).toISOString(); return `[CommitLabs Auth V2]\nDomain: ${domain}\nNonce: ${nonce}\nIssuedAt: ${issuedAt}\nExpiresAt: ${expiresAt}`; } -// ─── Session Management ─────────────────────────────────────────────────────── - -/** - * Create a session token after successful verification and store it. - */ export function createSessionToken(address: string): string { const token = `session_${randomBytes(16).toString("hex")}`; + const csrfToken = randomBytes(16).toString("hex"); const now = new Date(); const expiresAt = new Date(now.getTime() + SESSION_TTL); - const record: SessionRecord = { + sessionStore.set(token, { token, address, + csrfToken, createdAt: now, expiresAt, - }; + }); - sessionStore.set(token, record); return token; } -/** - * Verify a session token. - */ export function verifySessionToken(token: string): { valid: boolean; address?: string; + csrfToken?: string; + error?: string; } { const record = sessionStore.get(token); if (!record) { - return { valid: false }; + return { valid: false, error: "Session not found" }; } if (record.expiresAt < new Date()) { sessionStore.delete(token); - return { valid: false }; + return { valid: false, error: "Session expired" }; } - return { valid: true, address: record.address }; + return { + valid: true, + address: record.address, + csrfToken: record.csrfToken, + }; } -/** - * Invalidate a session token. - */ export function revokeSession(token: string): boolean { return sessionStore.delete(token); } -/** - * Export for testing purposes (in-memory store) - * @internal - */ export function _clearStores(): void { - nonceStore.clear(); sessionStore.clear(); } - -/** - * Clean up expired and revoked tokens periodically. - */ -setInterval(() => { - const now = Date.now(); - for (const [token, record] of sessionStore.entries()) { - // Remove revoked tokens after 7 days - if (record.revoked && record.revokedAt && - now - record.revokedAt.getTime() > 7 * 24 * 60 * 60 * 1000) { - sessionStore.delete(token); - } - } -}, 60 * 60 * 1000); // Clean up every hour diff --git a/tests/api/commitments-write-rate-limit.test.ts b/tests/api/commitments-write-rate-limit.test.ts index 8db099d..a4a3d94 100644 --- a/tests/api/commitments-write-rate-limit.test.ts +++ b/tests/api/commitments-write-rate-limit.test.ts @@ -41,15 +41,23 @@ vi.mock('@/lib/backend/services/contracts', () => ({ createCommitmentOnChain: vi.fn(), getCommitmentFromChain: vi.fn(), settleCommitmentOnChain: vi.fn(), + earlyExitCommitmentOnChain: vi.fn(), })); vi.mock('@/lib/backend/csrf', () => ({ assertMutationCsrf: vi.fn(), })); +vi.mock('@/lib/backend/requireAuth', () => ({ + requireAuth: vi.fn(), +})); + import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { requireAuth } from '@/lib/backend/requireAuth'; const mockedCheckRateLimit = vi.mocked(checkRateLimit); +const mockedRequireAuth = vi.mocked(requireAuth); +const VALID_ADDRESS = `G${'A'.repeat(55)}`; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -173,7 +181,12 @@ describe('POST /api/commitments/[id]/settle — rate limiting', () => { // ───────────────────────────────────────────────────────────────────────────── describe('POST /api/commitments/[id]/early-exit — rate limiting', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockedRequireAuth.mockReturnValue({ + user: { address: VALID_ADDRESS, csrfToken: 'csrf-token' }, + } as any); + }); it('returns 429 with Retry-After when rate limit is exceeded', async () => { mockedCheckRateLimit.mockResolvedValue(false); @@ -203,10 +216,26 @@ describe('POST /api/commitments/[id]/early-exit — rate limiting', () => { }); it('allows the request through when rate limit is not exceeded', async () => { + const { getCommitmentFromChain, earlyExitCommitmentOnChain } = await import('@/lib/backend/services/contracts'); + vi.mocked(getCommitmentFromChain).mockResolvedValue({ + id: 'abc', + ownerAddress: VALID_ADDRESS, + status: 'ACTIVE', + } as any); + vi.mocked(earlyExitCommitmentOnChain).mockResolvedValue({ + exitAmount: '95', + penaltyAmount: '5', + finalStatus: 'EARLY_EXIT', + txHash: 'tx-1', + reference: 'ref-1', + } as any); mockedCheckRateLimit.mockResolvedValue(true); const { POST } = await import('@/app/api/commitments/[id]/early-exit/route'); - const req = postRequest('http://localhost:3000/api/commitments/abc/early-exit', {}); + const req = postRequest('http://localhost:3000/api/commitments/abc/early-exit', { + reason: 'Need liquidity', + callerAddress: VALID_ADDRESS, + }); const response = await POST(req, createMockRouteContext({ id: 'abc' })); expect(response.status).not.toBe(429);