Skip to content
Open
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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@
# Anchor API variables
# ============================================
ANCHOR_API_BASE_URL=https://api.example.com/anchor
ANCHOR_API_KEY=your_anchor_api_key_here
ANCHOR_DEPOSIT_PATH=/transactions/deposit/interactive
ANCHOR_WITHDRAW_PATH=/transactions/withdraw/interactive
STELLAR_NETWORK="testnet"
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
INSURANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
REMITTANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
SAVINGS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
BILLS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"

# Session encryption for wallet-based auth (required for /api/auth/*).
# Generate with: openssl rand -base64 32
SESSION_PASSWORD=your-32-char-minimum-secret-here

# Shared secret used to verify Anchor webhooks
ANCHOR_WEBHOOK_SECRET=your_shared_anchor_secret_here

# Admin/internal route protection
ADMIN_SECRET=replace_with_rotatable_admin_secret

# Graceful shutdown timeout for in-process background jobs (milliseconds)
SHUTDOWN_TIMEOUT_MS=15000
# Soroban/Stellar Configuration

# ============================================
# Stellar / Soroban Network
Expand Down
6 changes: 6 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org
# Soroban Contract Addresses
NEXT_PUBLIC_SAVINGS_GOALS_CONTRACT_ID=your_contract_id_here

# Backend/server variables (set in deployment environment)
SESSION_PASSWORD=your-32-char-minimum-secret-here
ANCHOR_API_BASE_URL=https://api.example.com/anchor
ANCHOR_API_KEY=your_anchor_api_key_here
ANCHOR_WEBHOOK_SECRET=your_shared_anchor_secret_here
ADMIN_SECRET=replace_with_rotatable_admin_secret
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ See [API Routes Documentation](./docs/API_ROUTES.md) for details on authenticati
- Handle deposit/withdrawal flows
- Process exchange rate quotes
- **Environment Setup:** Set `ANCHOR_API_BASE_URL` in your `.env` file (see `.env.example`).
- **Frontend API Endpoint:** Use `GET /api/anchor/rates` to fetch cached exchange rates with fallback support.
- **Frontend API Endpoints:**
- `GET /api/anchor/rates` (cached exchange rates)
- `POST /api/anchor/deposit` (authenticated; starts fiat -> USDC flow)
- `POST /api/anchor/withdraw` (authenticated; starts USDC -> fiat flow)
- Deposit/withdraw returns `501 Not Implemented` when anchor integration is not configured.

4. **Transaction Tracking**
- Display on-chain transaction history
Expand Down Expand Up @@ -247,8 +251,17 @@ API_RATE_LIMIT=100 # requests per minute
API_TIMEOUT=30000 # milliseconds

# Optional: Anchor Platform
ANCHOR_PLATFORM_URL=
ANCHOR_PLATFORM_API_KEY=
ANCHOR_API_BASE_URL=
ANCHOR_API_KEY=
ANCHOR_DEPOSIT_PATH=/transactions/deposit/interactive
ANCHOR_WITHDRAW_PATH=/transactions/withdraw/interactive
ANCHOR_WEBHOOK_SECRET=

# Admin / internal APIs
ADMIN_SECRET=

# In-process background shutdown timeout
SHUTDOWN_TIMEOUT_MS=15000
```

See `.env.example` for a complete list of configuration options.
Expand Down Expand Up @@ -483,6 +496,7 @@ Integration tests are in the `__tests__/integration/` directory.
For detailed API specifications:

- **OpenAPI Spec**: See `openapi.yaml` for complete API documentation
- **Anchor/Admin/Shutdown Notes**: See `docs/ANCHOR_ADMIN_SHUTDOWN.md`
- **Interactive Docs**: Run `npm run docs` to view Swagger UI at `http://localhost:3000/api-docs`
- **Postman Collection**: Import `postman_collection.json` for testing

Expand All @@ -507,8 +521,37 @@ POST /api/contracts/family # Manage family wallet

# System
GET /api/health # Health check
POST /api/anchor/deposit # Start anchor deposit flow
POST /api/anchor/withdraw # Start anchor withdrawal flow
POST /api/admin/cache/clear # Admin-only cache invalidation
GET /api/admin/users # Admin-only recent users
GET /api/admin/audit # Admin-only audit events
```

### Anchor deposit/withdraw flow notes

- `POST /api/anchor/deposit` body: `{ "amount": 100, "currency": "USD", "destination": "optional" }`
- `POST /api/anchor/withdraw` body: `{ "amount": 100, "currency": "USD", "destinationAccount": "optional" }`
- Routes call configured anchor interactive endpoints and return either `url` (interactive flow) and/or `steps`.
- Pending flows are stored in-memory so webhook callbacks can reconcile status updates.

### Admin route security

- All `/api/admin/*` routes require `ADMIN_SECRET`.
- Authorized inputs:
- Header: `X-Admin-Key: <ADMIN_SECRET>`
- Cookie: `admin_key=<ADMIN_SECRET>` (or `admin_secret=<ADMIN_SECRET>`)
- Rotate `ADMIN_SECRET` regularly; optionally restrict admin routes with an upstream IP allowlist.

### Graceful shutdown for in-process jobs

- In-process background work (nonce cache sweeper and webhook async handlers) now registers `SIGTERM` / `SIGINT` handlers.
- On shutdown:
- New background jobs are rejected.
- Active jobs are awaited up to `SHUTDOWN_TIMEOUT_MS` (default `15000` ms).
- Process exits after hooks and timeout race complete.
- If production jobs run externally (Vercel Cron or a separate worker), this in-process shutdown path can remain idle.

## Design Notes

- All forms are currently disabled (placeholders)
Expand Down
2 changes: 2 additions & 0 deletions app/api/admin/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { GET } from '@/app/api/v1/admin/audit/route';

2 changes: 2 additions & 0 deletions app/api/admin/cache/clear/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { POST } from '@/app/api/v1/admin/cache/clear/route';

2 changes: 2 additions & 0 deletions app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { GET } from '@/app/api/v1/admin/users/route';

2 changes: 2 additions & 0 deletions app/api/anchor/deposit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { POST } from '@/app/api/v1/anchor/deposit/route';

21 changes: 4 additions & 17 deletions app/api/anchor/rates/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { NextResponse } from 'next/server';
import { anchorClient, ExchangeRate } from '@/lib/anchor/client';
import { anchorClient } from '@/lib/anchor/client';
import { getAnchorRatesCache, setAnchorRatesCache } from '@/lib/anchor/rates-cache';

export const dynamic = 'force-dynamic';

interface CacheData {
rates: ExchangeRate[] | null;
timestamp: number;
}

// In-memory cache variables for rates.
// Next.js development server may drop this cache on hot-reload, but it will work effectively in a production environment (serverless instance lifetime).
let rateCache: CacheData = {
rates: null,
timestamp: 0,
};

// 5 minutes in milliseconds
const CACHE_TTL = 5 * 60 * 1000;

export async function GET() {
const rateCache = getAnchorRatesCache();
const now = Date.now();
const isCacheValid = rateCache.rates !== null && (now - rateCache.timestamp) < CACHE_TTL;

Expand All @@ -33,10 +23,7 @@ export async function GET() {
const fetchedRates = await anchorClient.getExchangeRates();

// Update the cache
rateCache = {
rates: fetchedRates,
timestamp: now,
};
setAnchorRatesCache(fetchedRates, now);

return NextResponse.json({
rates: fetchedRates,
Expand Down
2 changes: 2 additions & 0 deletions app/api/anchor/withdraw/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { POST } from '@/app/api/v1/anchor/withdraw/route';

24 changes: 17 additions & 7 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function POST(request: NextRequest) {
}

// Verify nonce exists and matches (atomic read + delete)
// Note: getAndClearNonce usually handles the deletion internally
const storedNonce = getAndClearNonce(address);
if (!storedNonce || storedNonce !== message) {
return NextResponse.json(
Expand All @@ -42,12 +43,21 @@ export async function POST(request: NextRequest) {
}

// Verify signature
// The client signs Buffer.from(nonce, 'utf8') so we must decode the same way
const keypair = Keypair.fromPublicKey(address);
const messageBuffer = Buffer.from(message, 'utf8');
const signatureBuffer = Buffer.from(signature, 'base64');

const isValid = keypair.verify(messageBuffer, signatureBuffer);
let isValid = false;
try {
const keypair = Keypair.fromPublicKey(address);
// Most Stellar wallets sign the UTF-8 representation of the string
const messageBuffer = Buffer.from(message, 'utf8');
const signatureBuffer = Buffer.from(signature, 'base64');

isValid = keypair.verify(messageBuffer, signatureBuffer);
} catch (verifyError) {
console.error('Signature verification technical error:', verifyError);
return NextResponse.json(
{ error: 'Malformed signature or address' },
{ status: 400 }
);
}

if (!isValid) {
return NextResponse.json(
Expand All @@ -72,7 +82,7 @@ export async function POST(request: NextRequest) {
console.warn('DB upsert skipped (non-fatal):', dbErr);
}

// Create encrypted session
// Create encrypted session (production logic from main)
const sealed = await createSession(address);

const response = NextResponse.json({
Expand Down
83 changes: 67 additions & 16 deletions app/api/auth/nonce/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { setNonce } from "@/lib/auth-cache";
import { randomBytes } from "crypto";
import { randomBytes } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { storeNonce } from '@/lib/auth/nonce-store';
import { StrKey } from '@stellar/stellar-sdk';

export async function POST(request: NextRequest) {
const { publicKey } = await request.json();
// Force dynamic rendering to ensure fresh nonces
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

if (!publicKey) {
return NextResponse.json(
{ error: "publicKey is required" },
{ status: 400 },
);
/**
* Validates a Stellar address
*/
function isValidStellarAddress(address: string): boolean {
return StrKey.isValidEd25519PublicKey(address);
}

/**
* Extracts address from query params or request body
*/
async function resolveAddressFromRequest(request: NextRequest): Promise<string | null> {
const queryAddress = request.nextUrl.searchParams.get('address')?.trim();
if (queryAddress) return queryAddress;

if (request.method === 'POST') {
try {
const body = await request.clone().json();
const address = (body.publicKey || body.address);
if (typeof address === 'string') return address.trim();
} catch {
// Ignore body parsing errors
}
}

// Generate a random nonce (32 bytes) and convert to hex
const nonceBuffer = randomBytes(32);
const nonce = nonceBuffer.toString("hex");
return null;
}

async function handleNonceRequest(request: NextRequest) {
try {
const address = await resolveAddressFromRequest(request);

if (!address || !isValidStellarAddress(address)) {
return NextResponse.json(
{ error: 'Valid Stellar address is required (e.g., ?address=G...)' },
{ status: 400 }
);
}

// Store nonce in cache for later verification
setNonce(publicKey, nonce);
// Generate a 32-byte random nonce and convert to hex
const nonce = randomBytes(32).toString('hex');

return NextResponse.json({ nonce });
// Store nonce
storeNonce(address, nonce);

return NextResponse.json({
nonce,
address,
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
} catch (error) {
console.error('Error generating nonce:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

export async function GET(request: NextRequest) {
return handleNonceRequest(request);
}

export async function POST(request: NextRequest) {
return handleNonceRequest(request);
}
18 changes: 18 additions & 0 deletions app/api/v1/admin/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAdminAuthorized } from '@/lib/admin/auth';
import { getAuditEvents } from '@/lib/admin/audit';

export async function GET(request: NextRequest) {
if (!isAdminAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const limitRaw = request.nextUrl.searchParams.get('limit');
const parsed = Number(limitRaw ?? 50);
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 200) : 50;

return NextResponse.json({
events: getAuditEvents(limit),
});
}

29 changes: 29 additions & 0 deletions app/api/v1/admin/cache/clear/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAdminAuthorized, getAdminIdentity } from '@/lib/admin/auth';
import { clearRegisteredCaches, listRegisteredCaches } from '@/lib/cache/registry';
import { recordAuditEvent } from '@/lib/admin/audit';
import '@/lib/auth-cache';
import '@/lib/anchor/rates-cache';

export async function POST(request: NextRequest) {
if (!isAdminAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const availableCaches = listRegisteredCaches();
const clearedCaches = await clearRegisteredCaches();
const actor = getAdminIdentity(request);

recordAuditEvent({
type: 'admin.cache.clear',
actor,
message: 'Cleared in-memory caches',
metadata: { availableCaches, clearedCaches },
});

return NextResponse.json({
ok: true,
clearedCaches,
});
}

36 changes: 36 additions & 0 deletions app/api/v1/admin/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAdminAuthorized } from '@/lib/admin/auth';
import prisma from '@/lib/db';

export async function GET(request: NextRequest) {
if (!isAdminAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const limitRaw = request.nextUrl.searchParams.get('limit');
const parsed = Number(limitRaw ?? 20);
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 100) : 20;

try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
stellar_address: true,
createdAt: true,
},
});

return NextResponse.json({
users: users.map((u: { id: string; stellar_address: string; createdAt: Date }) => ({
id: u.id,
stellarAddress: u.stellar_address,
createdAt: u.createdAt.toISOString(),
})),
});
} catch (error) {
console.error('[GET /api/v1/admin/users]', error);
return NextResponse.json({ error: 'Failed to list users' }, { status: 500 });
}
}
Loading
Loading