Skip to content

Commit d0c026f

Browse files
authored
Merge branch 'main' into feat/request-validation
2 parents 00e9743 + b7f1c56 commit d0c026f

36 files changed

+8014
-6326
lines changed

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,12 @@ SESSION_PASSWORD=your-32-char-minimum-secret-here
1313

1414
# Shared secret used to verify Anchor webhooks
1515
ANCHOR_WEBHOOK_SECRET=your_shared_anchor_secret_here
16+
17+
# API Middleware Configuration
18+
# Frontend application URL for CORS policy (required for /api routes)
19+
# Requests from this origin will be allowed by CORS middleware
20+
NEXT_PUBLIC_APP_URL=http://localhost:3000
21+
22+
# Maximum request body size for POST/PUT/PATCH in bytes (default 1MB)
23+
# Requests exceeding this size will receive a 413 Payload Too Large response
24+
API_MAX_BODY_SIZE=1048576

README.md

Lines changed: 178 additions & 43 deletions
Large diffs are not rendered by default.

app/api/auth/nonce/route.ts

Lines changed: 17 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,23 @@
1-
import { NextRequest, NextResponse } from 'next/server';
2-
import { randomBytes } from 'crypto';
3-
import { storeNonce } from '@/lib/auth/nonce-store';
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { setNonce } from "@/lib/auth-cache";
3+
import { randomBytes } from "crypto";
44

5-
// Force dynamic rendering for this route
6-
export const dynamic = 'force-dynamic';
7-
export const runtime = 'nodejs';
5+
export async function POST(request: NextRequest) {
6+
const { publicKey } = await request.json();
87

9-
/**
10-
* GET /api/auth/nonce
11-
* Generate a nonce for signature-based authentication
12-
*
13-
* Query Parameters:
14-
* - address: Stellar address requesting the nonce
15-
*/
16-
export async function GET(request: NextRequest) {
17-
try {
18-
const address = request.nextUrl.searchParams.get('address');
8+
if (!publicKey) {
9+
return NextResponse.json(
10+
{ error: "publicKey is required" },
11+
{ status: 400 },
12+
);
13+
}
1914

20-
if (!address) {
21-
return NextResponse.json(
22-
{ error: 'Missing required query parameter: address' },
23-
{ status: 400 }
24-
);
25-
}
15+
// Generate a random nonce (32 bytes) and convert to hex
16+
const nonceBuffer = randomBytes(32);
17+
const nonce = nonceBuffer.toString("hex");
2618

27-
// Validate Stellar address format (G + 55 alphanumeric characters)
28-
if (!/^G[A-Z0-9]{55}$/.test(address)) {
29-
return NextResponse.json(
30-
{ error: 'Invalid Stellar address format' },
31-
{ status: 400 }
32-
);
33-
}
19+
// Store nonce in cache for later verification
20+
setNonce(publicKey, nonce);
3421

35-
// Generate a random nonce
36-
const nonce = randomBytes(32).toString('hex');
37-
38-
// Store nonce with 5 minute expiration
39-
storeNonce(address, nonce);
40-
41-
return NextResponse.json({
42-
nonce,
43-
address,
44-
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
45-
});
46-
} catch (error) {
47-
console.error('Error generating nonce:', error);
48-
return NextResponse.json(
49-
{ error: 'Internal Server Error' },
50-
{ status: 500 }
51-
);
52-
}
22+
return NextResponse.json({ nonce });
5323
}

app/api/bills/[id]/route.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getBill } from '@/lib/contracts/bill-payments';
3+
import { jsonSuccess, jsonError } from '@/lib/api/types';
4+
5+
export const runtime = 'nodejs';
6+
7+
export async function GET(
8+
request: NextRequest,
9+
context: { params: Promise<{ id: string }> }
10+
): Promise<NextResponse> {
11+
const authHeader = request.headers.get('authorization') ?? '';
12+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
13+
14+
if (!token || token !== process.env.AUTH_SECRET) {
15+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16+
}
17+
18+
try {
19+
const { id } = await context.params;
20+
21+
if (!id) {
22+
return jsonError('VALIDATION_ERROR', 'Bill ID is required');
23+
}
24+
25+
const owner = new URL(request.url).searchParams.get('owner') ?? token;
26+
const bill = await getBill(id, owner);
27+
28+
return jsonSuccess({ bill });
29+
} catch (err: unknown) {
30+
if (err instanceof Error && err.message === 'not-found') {
31+
return jsonError('NOT_FOUND', 'Bill not found');
32+
}
33+
console.error('[GET /api/bills/[id]]', err);
34+
return jsonError('INTERNAL_ERROR', 'Failed to fetch bill');
35+
}
36+
}

app/api/bills/route.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,68 @@ async function getHandler(request: NextRequest) {
3838

3939

4040
export const GET = compose(withAuth)(getHandler);
41-
export const POST = compose(withAuth)(addBillHandler);
41+
export const POST = compose(withAuth)(addBillHandler);
42+
import { NextRequest, NextResponse } from 'next/server';
43+
import { withAuth } from '@/lib/auth';
44+
import { getAllBills, getBill } from '@/lib/contracts/bill-payments';
45+
import { jsonSuccess, jsonError } from '@/lib/api/types';
46+
47+
async function getHandler(request: NextRequest, session: string) {
48+
try {
49+
const { searchParams } = new URL(request.url);
50+
const id = searchParams.get('id');
51+
const unpaidOnly = searchParams.get('unpaid') === 'true';
52+
const owner = searchParams.get('owner') ?? session;
53+
54+
// GET /api/bills?id=1 — return single bill
55+
if (id) {
56+
try {
57+
const bill = await getBill(id, owner);
58+
return jsonSuccess({ bill });
59+
} catch (err: unknown) {
60+
if (err instanceof Error && err.message === 'not-found') {
61+
return jsonError('NOT_FOUND', 'Bill not found');
62+
}
63+
throw err;
64+
}
65+
}
66+
67+
// GET /api/bills — return all or unpaid bills
68+
const bills = await getAllBills(owner);
69+
const result = unpaidOnly ? bills.filter((b) => b.status !== 'paid') : bills;
70+
return jsonSuccess({ bills: result });
71+
} catch (err) {
72+
console.error('[GET /api/bills]', err);
73+
return jsonError('INTERNAL_ERROR', 'Failed to fetch bills');
74+
}
75+
}
76+
77+
async function postHandler(request: NextRequest, session: string) {
78+
try {
79+
const body = await request.json();
80+
const { name, amount, dueDate, recurring, frequencyDays } = body;
81+
82+
if (!name || typeof name !== 'string') {
83+
return jsonError('VALIDATION_ERROR', 'name is required');
84+
}
85+
if (!(amount > 0)) {
86+
return jsonError('VALIDATION_ERROR', 'amount must be greater than 0');
87+
}
88+
if (!dueDate || Number.isNaN(Date.parse(dueDate))) {
89+
return jsonError('VALIDATION_ERROR', 'dueDate must be a valid ISO date string');
90+
}
91+
if (recurring && !(frequencyDays > 0)) {
92+
return jsonError('VALIDATION_ERROR', 'frequencyDays must be greater than 0 when recurring is true');
93+
}
94+
95+
return jsonSuccess({
96+
message: 'Validation passed. Use POST /api/bills with owner public key to build XDR.',
97+
});
98+
} catch (err) {
99+
console.error('[POST /api/bills]', err);
100+
return jsonError('INTERNAL_ERROR', 'Failed to process bill creation');
101+
}
102+
}
103+
104+
export const GET = withAuth(getHandler);
105+
export const POST = withAuth(postHandler);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getTotalUnpaid, getUnpaidBills } from '@/lib/contracts/bill-payments';
3+
import { jsonSuccess, jsonError } from '@/lib/api/types';
4+
5+
export async function GET(request: NextRequest): Promise<NextResponse> {
6+
const authHeader = request.headers.get('authorization') ?? '';
7+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null;
8+
9+
if (!token || token !== process.env.AUTH_SECRET) {
10+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11+
}
12+
13+
try {
14+
const owner = new URL(request.url).searchParams.get('owner') ?? token;
15+
16+
const [total, bills] = await Promise.all([
17+
getTotalUnpaid(owner),
18+
getUnpaidBills(owner),
19+
]);
20+
21+
return jsonSuccess({
22+
totalUnpaid: total,
23+
count: bills.length,
24+
bills,
25+
});
26+
} catch (err) {
27+
console.error('[GET /api/bills/total-unpaid]', err);
28+
return jsonError('INTERNAL_ERROR', 'Failed to fetch total unpaid bills');
29+
}
30+
}

app/api/docs/SwaggerUIWrapper.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import 'swagger-ui-react/swagger-ui.css';
4+
import { useEffect, useState } from 'react';
5+
import SwaggerUI from 'swagger-ui-react';
6+
7+
type SwaggerUIWrapperProps = {
8+
specUrl: string;
9+
};
10+
11+
export default function SwaggerUIWrapper({ specUrl }: SwaggerUIWrapperProps) {
12+
const [mounted, setMounted] = useState(false);
13+
14+
useEffect(() => {
15+
setMounted(true);
16+
}, []);
17+
18+
if (!mounted) {
19+
return <div className="p-8 text-center text-lg">Loading API Spec...</div>;
20+
}
21+
22+
return (
23+
<div className="bg-white min-h-screen">
24+
<SwaggerUI url={specUrl} />
25+
</div>
26+
);
27+
}

app/api/docs/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import SwaggerUIWrapper from './SwaggerUIWrapper';
2+
3+
export default function ApiDocs() {
4+
return <SwaggerUIWrapper specUrl="/api/docs/spec" />;
5+
}

app/api/docs/spec/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { NextResponse } from 'next/server';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
export async function GET() {
6+
try {
7+
const filePath = path.join(process.cwd(), 'openapi.yaml');
8+
const fileContents = fs.readFileSync(filePath, 'utf8');
9+
return new NextResponse(fileContents, {
10+
status: 200,
11+
headers: { 'Content-Type': 'text/yaml' },
12+
});
13+
} catch (error) {
14+
console.error('Failed to read openapi.yaml:', error);
15+
return new NextResponse('Internal Server Error', { status: 500 });
16+
}
17+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { getGoal, isGoalCompleted } from "@/lib/contracts/savings-goal";
2+
import { NextResponse, NextRequest } from "next/server";
3+
4+
export async function GET(
5+
req: NextRequest,
6+
context: { params: Promise<{ id: string }> } // ← note Promise
7+
) {
8+
try {
9+
const { id } = await context.params;
10+
11+
const publicKey = req.headers.get("x-public-key");
12+
13+
if (!publicKey) {
14+
return NextResponse.json(
15+
{ error: "Unauthorized" },
16+
{ status: 401 }
17+
);
18+
}
19+
20+
const goal = await getGoal(id);
21+
22+
if (!goal) {
23+
return NextResponse.json(
24+
{ error: "Goal not found" },
25+
{ status: 404 }
26+
);
27+
}
28+
29+
const completed = await isGoalCompleted(id);
30+
31+
return NextResponse.json({ completed }, { status: 200 });
32+
} catch (error) {
33+
console.error(`GET /api/goals/${await context.params}/completed error:`, error);
34+
return NextResponse.json(
35+
{ error: "Failed to check goal status" },
36+
{ status: 500 }
37+
);
38+
}
39+
}

0 commit comments

Comments
 (0)