Skip to content
Merged
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
97 changes: 97 additions & 0 deletions docs/backend-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,103 @@ curl http://localhost:3000/api/protocol/constants

---

## `POST /api/commitments/[id]/dispute`

Opens a dispute for the named commitment. Calls the escrow contract's
`dispute` method and records an audit log event.

- **Path parameter**: `id` (string) — the commitment ID
- **Request body**:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `reason` | string | Yes | Reason for the dispute (1–500 characters) |
| `evidence` | string | No | Optional URL or reference to supporting evidence |
| `callerAddress` | string | No | Stellar address of the caller (defaults to commitment owner) |

- **Response**: dispute details including `disputeId`, `status`, and `txHash`.

### Example

```bash
curl -X POST http://localhost:3000/api/commitments/abc123/dispute \
-H 'Content-Type: application/json' \
-d '{"reason":"Payment not received","evidence":"https://example.com/proof"}'
```

```json
{
"success": true,
"data": {
"commitmentId": "abc123",
"disputeId": "DSP-001",
"status": "DISPUTED",
"txHash": "0xdispute123",
"disputedAt": "2026-05-28T14:00:00.000Z"
}
}
```

### Error Responses

| Status | Condition |
|--------|-----------|
| 400 | Missing or invalid `reason`, empty commitment ID |
| 409 | Commitment already settled, exited, or already in dispute |
| 502 | Blockchain call failed |

---

## `POST /api/commitments/[id]/resolve`

Resolves an open dispute on a commitment. **Admin access only** — the caller
must authenticate with a valid Bearer token and the address must be listed in
`ADMIN_ADDRESSES`. Calls the escrow contract's `resolve_dispute` method and
records an audit log event.

- **Path parameter**: `id` (string) — the commitment ID
- **Authentication**: Bearer token required (admin only)
- **Request body**:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `resolution` | enum | Yes | One of: `resolved_in_favor_of_owner`, `resolved_in_favor_of_counterparty`, `dismissed` |
| `notes` | string | No | Optional resolution notes (max 1000 characters) |

- **Response**: resolution details including `disputeId`, `resolution`, and `finalStatus`.

### Example

```bash
curl -X POST http://localhost:3000/api/commitments/abc123/resolve \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <session_token>' \
-d '{"resolution":"resolved_in_favor_of_owner","notes":"Evidence reviewed and validated"}'
```

```json
{
"success": true,
"data": {
"commitmentId": "abc123",
"disputeId": "DSP-001",
"resolution": "resolved_in_favor_of_owner",
"finalStatus": "ACTIVE",
"txHash": "0xresolve123",
"resolvedAt": "2026-05-28T14:30:00.000Z"
}
}
```

### Error Responses

| Status | Condition |
|--------|-----------|
| 400 | Missing or invalid `resolution`, empty commitment ID, notes too long |
| 401 | Missing or invalid Bearer token |
| 403 | Caller is not an admin |
| 409 | Commitment is not currently in dispute |
| 502 | Blockchain call failed |
## `GET /api/commitments/[id]/events`

Server-Sent Events (SSE) stream that pushes real-time commitment status updates and transitions (Active, Settled, Early Exit, Violated).
Expand Down
104 changes: 104 additions & 0 deletions src/app/api/commitments/[id]/dispute/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { checkRateLimit } from '@/lib/backend/rateLimit';
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok, methodNotAllowed } from '@/lib/backend/apiResponse';
import { TooManyRequestsError, ValidationError, NotFoundError, ConflictError } from '@/lib/backend/errors';
import { getClientIp } from '@/lib/backend/getClientIp';
import { openDisputeOnChain } from '@/lib/backend/services/contracts';
import { logDisputeOpened } from '@/lib/backend/logger';
import { recordAuditEvent } from '@/lib/backend/auditLog';

const DisputeRequestSchema = z.object({
reason: z.string().min(1, 'Dispute reason is required').max(500),
evidence: z.string().optional(),
callerAddress: z.string().optional(),
});

interface Params {
params: { id: string };
}

export const POST = withApiHandler(async (req: NextRequest, { params }: Params) => {
const { id } = params;
const ip = getClientIp(req);

const { allowed, retryAfterSeconds } = await checkRateLimit(ip, 'api/commitments/dispute');
if (!allowed) {
throw new TooManyRequestsError(undefined, undefined, retryAfterSeconds);
}

if (!id || id.trim().length === 0) {
throw new ValidationError('Commitment ID is required');
}

let body;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid JSON in request body');
}

const validation = DisputeRequestSchema.safeParse(body);
if (!validation.success) {
throw new ValidationError('Invalid request data', validation.error.errors);
}

const { reason, evidence, callerAddress } = validation.data;

try {
const disputeResult = await openDisputeOnChain({
commitmentId: id,
reason,
evidence,
callerAddress: callerAddress ?? '',
});

logDisputeOpened({
ip,
commitmentId: id,
reason,
callerAddress,
disputeId: disputeResult.disputeId,
txHash: disputeResult.txHash,
});

recordAuditEvent({
eventType: 'DISPUTE_OPENED',
actorAddress: callerAddress ?? '',
commitmentId: id,
details: {
reason,
evidence: evidence ?? '',
disputeId: disputeResult.disputeId,
txHash: disputeResult.txHash,
},
});

return ok({
commitmentId: id,
disputeId: disputeResult.disputeId,
status: disputeResult.status,
txHash: disputeResult.txHash,
disputedAt: disputeResult.disputedAt,
});
} catch (error) {
logDisputeOpened({
ip,
commitmentId: id,
reason,
callerAddress,
error: error instanceof Error ? error.message : 'Unknown dispute error',
});

if (
error instanceof ValidationError ||
error instanceof NotFoundError ||
error instanceof ConflictError
) {
throw error;
}

throw error;
}
});
107 changes: 107 additions & 0 deletions src/app/api/commitments/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { checkRateLimit } from '@/lib/backend/rateLimit';
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';
import { TooManyRequestsError, ValidationError, ConflictError, ForbiddenError } from '@/lib/backend/errors';
import { getClientIp } from '@/lib/backend/getClientIp';
import { resolveDisputeOnChain } from '@/lib/backend/services/contracts';
import { logDisputeResolved } from '@/lib/backend/logger';
import { recordAuditEvent } from '@/lib/backend/auditLog';
import { requireAdmin } from '@/lib/backend/requireAuth';

const ResolveDisputeRequestSchema = z.object({
resolution: z.enum(['resolved_in_favor_of_owner', 'resolved_in_favor_of_counterparty', 'dismissed']),
notes: z.string().max(1000).optional(),
});

interface Params {
params: { id: string };
}

export const POST = withApiHandler(async (req: NextRequest, { params }: Params) => {
const { id } = params;
const ip = getClientIp(req);

const { allowed, retryAfterSeconds } = await checkRateLimit(ip, 'api/commitments/resolve');
if (!allowed) {
throw new TooManyRequestsError(undefined, undefined, retryAfterSeconds);
}

if (!id || id.trim().length === 0) {
throw new ValidationError('Commitment ID is required');
}

const admin = requireAdmin(req);

let body;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid JSON in request body');
}

const validation = ResolveDisputeRequestSchema.safeParse(body);
if (!validation.success) {
throw new ValidationError('Invalid request data', validation.error.errors);
}

const { resolution, notes } = validation.data;

try {
const resolveResult = await resolveDisputeOnChain({
commitmentId: id,
resolution,
notes,
resolverAddress: admin.address,
});

logDisputeResolved({
ip,
commitmentId: id,
resolution,
resolverAddress: admin.address,
disputeId: resolveResult.disputeId,
txHash: resolveResult.txHash,
});

recordAuditEvent({
eventType: 'DISPUTE_RESOLVED',
actorAddress: admin.address,
commitmentId: id,
details: {
resolution,
notes: notes ?? '',
disputeId: resolveResult.disputeId,
txHash: resolveResult.txHash,
},
});

return ok({
commitmentId: id,
disputeId: resolveResult.disputeId,
resolution: resolveResult.resolution,
finalStatus: resolveResult.finalStatus,
txHash: resolveResult.txHash,
resolvedAt: resolveResult.resolvedAt,
});
} catch (error) {
logDisputeResolved({
ip,
commitmentId: id,
resolution,
resolverAddress: admin.address,
error: error instanceof Error ? error.message : 'Unknown resolution error',
});

if (
error instanceof ValidationError ||
error instanceof ConflictError ||
error instanceof ForbiddenError
) {
throw error;
}

throw error;
}
});
4 changes: 4 additions & 0 deletions src/lib/backend/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,9 @@
response.headers.set("x-request-id", correlationId);
}

return NextResponse.json(body, {

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > rate limiting > returns 429 when rate limited

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:585:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > validation > rejects non-numeric minCompliance

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:538:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > validation > rejects empty ownerAddress

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:532:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > validation > requires ownerAddress

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:521:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > pagination > rejects pageSize > 100

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:387:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > pagination > rejects page < 1

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:377:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > minCompliance filter > rejects negative compliance

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:275:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > minCompliance filter > rejects compliance > 100

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:267:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > riskType filter > rejects invalid risk type

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:232:19

Check failure on line 143 in src/lib/backend/apiResponse.ts

View workflow job for this annotation

GitHub Actions / Vitest Coverage

tests/api/commitment-search.test.ts > GET /api/commitments/search > status filter > rejects invalid status values

ReferenceError: body is not defined ❯ fail src/lib/backend/apiResponse.ts:143:28 ❯ Module.wrappedHandler src/lib/backend/withApiHandler.ts:87:26 ❯ tests/api/commitment-search.test.ts:204:19
status,
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
return response;
}
42 changes: 42 additions & 0 deletions src/lib/backend/auditLog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
import { randomUUID } from 'crypto';

export type AuditEventType =
| 'DISPUTE_OPENED'
| 'DISPUTE_RESOLVED'
| 'DISPUTE_RESOLVED_FAILED'
| 'DISPUTE_OPEN_FAILED';

export interface AuditLogEntry {
id: string;
eventType: AuditEventType;
timestamp: string;
actorAddress: string;
commitmentId: string;
details: Record<string, unknown>;
}

const auditLogStore: AuditLogEntry[] = [];

export function recordAuditEvent(entry: Omit<AuditLogEntry, 'id' | 'timestamp'>): AuditLogEntry {
const logEntry: AuditLogEntry = {
id: randomUUID(),
timestamp: new Date().toISOString(),
...entry,
};

auditLogStore.push(logEntry);

console.log(JSON.stringify({
event: 'AuditLog',
...logEntry,
}));

return logEntry;
}

export function getAuditLog(commitmentId: string): AuditLogEntry[] {
return auditLogStore.filter(entry => entry.commitmentId === commitmentId);
}

export function clearAuditLog(): void {
auditLogStore.length = 0;
/**
* Audit Event Store
*
Expand Down
16 changes: 16 additions & 0 deletions src/lib/backend/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,22 @@ export function logListingCancellationFailed(payload: AnalyticsPayload = {}) {
});
}

export function logDisputeOpened(payload: AnalyticsPayload = {}) {
emit({
event: 'DisputeOpened',
timestamp: new Date().toISOString(),
payload
});
}

export function logDisputeResolved(payload: AnalyticsPayload = {}) {
emit({
event: 'DisputeResolved',
timestamp: new Date().toISOString(),
payload
});
}

export const logger = {
info: (message: string, context?: Record<string, unknown>) =>
logInfo(undefined, message, context),
Expand Down
Loading
Loading