diff --git a/README.md b/README.md index 1b00ed8..96a6806 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,18 @@ The FlowFi backend API uses URL-based versioning. All endpoints are prefixed wit - **API Versioning Guide**: [backend/docs/API_VERSIONING.md](backend/docs/API_VERSIONING.md) - **Deprecation Policy**: [backend/docs/DEPRECATION_POLICY.md](backend/docs/DEPRECATION_POLICY.md) +- **Sandbox Mode**: [backend/docs/SANDBOX_MODE.md](backend/docs/SANDBOX_MODE.md) - Test without affecting production data - **API Docs**: Available at `http://localhost:3001/api-docs` when backend is running +### Sandbox Mode + +FlowFi supports sandbox mode for safe testing. Enable it by: + +1. Setting `SANDBOX_MODE_ENABLED=true` in your `.env` file +2. Adding `X-Sandbox-Mode: true` header or `?sandbox=true` query parameter to requests + +Sandbox mode uses a separate database and clearly labels all responses. See [Sandbox Mode Documentation](backend/docs/SANDBOX_MODE.md) for details. + ## Contributing Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for: diff --git a/backend/.env.example b/backend/.env.example index 2995651..280fad3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,3 +8,10 @@ NODE_ENV=development # Stellar Network (Testnet/Mainnet) STELLAR_NETWORK=testnet STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org + +# Enable sandbox mode +SANDBOX_MODE_ENABLED=true + +# Optional: Use a separate database for sandbox +# If not set, it will use {DATABASE_URL}_sandbox +SANDBOX_DATABASE_URL=file:./sandbox.db diff --git a/backend/docs/SANDBOX_MODE.md b/backend/docs/SANDBOX_MODE.md new file mode 100644 index 0000000..743074b --- /dev/null +++ b/backend/docs/SANDBOX_MODE.md @@ -0,0 +1,295 @@ +# Sandbox Mode + +Sandbox mode allows developers and new users to experiment with FlowFi API without touching real funds or affecting production data. + +## Overview + +When sandbox mode is enabled, all API requests are routed to a separate, isolated environment: +- **Separate database** - Complete data isolation from production +- **Clear labeling** - All responses include sandbox indicators +- **Safe testing** - No risk of affecting production data or real funds + +## Enabling Sandbox Mode + +### Server Configuration + +Sandbox mode must be enabled on the server via environment variables: + +```bash +# Enable sandbox mode globally +SANDBOX_MODE_ENABLED=true + +# Optional: Use a separate database for sandbox +SANDBOX_DATABASE_URL=file:./sandbox.db + +# Optional: Configure how sandbox mode is activated +SANDBOX_ALLOW_HEADER=true # Allow X-Sandbox-Mode header (default: true) +SANDBOX_ALLOW_QUERY_PARAM=true # Allow ?sandbox=true query param (default: true) +SANDBOX_HEADER_NAME=X-Sandbox-Mode # Custom header name (default: X-Sandbox-Mode) +SANDBOX_QUERY_PARAM_NAME=sandbox # Custom query param name (default: sandbox) +``` + +### Client Activation + +Once sandbox mode is enabled on the server, clients can activate it in two ways: + +#### 1. HTTP Header + +```bash +curl -H "X-Sandbox-Mode: true" \ + http://localhost:3001/v1/streams +``` + +#### 2. Query Parameter + +```bash +curl "http://localhost:3001/v1/streams?sandbox=true" +``` + +## Response Indicators + +### Response Headers + +All sandbox responses include: + +``` +X-Sandbox-Mode: true +X-Environment: sandbox +``` + +Production responses include: + +``` +X-Environment: production +``` + +### Response Body + +Sandbox responses include a `_sandbox` metadata object: + +```json +{ + "id": "123", + "status": "pending", + "sender": "GABC...", + "_sandbox": { + "mode": true, + "warning": "This is sandbox data and does not affect production", + "timestamp": "2024-02-21T14:30:00.000Z" + } +} +``` + +## Database Isolation + +### Default Behavior + +If `SANDBOX_DATABASE_URL` is not set, sandbox mode uses: +- SQLite: `{DATABASE_URL}_sandbox` +- Example: If `DATABASE_URL=file:./dev.db`, sandbox uses `file:./dev.db_sandbox` + +### Custom Database + +Set `SANDBOX_DATABASE_URL` to use a completely separate database: + +```bash +# Use a different SQLite file +SANDBOX_DATABASE_URL=file:./sandbox.db + +# Or use a different PostgreSQL database +SANDBOX_DATABASE_URL=postgresql://user:pass@localhost:5432/flowfi_sandbox +``` + +## Usage Examples + +### Creating a Stream in Sandbox Mode + +```bash +# Using header +curl -X POST http://localhost:3001/v1/streams \ + -H "Content-Type: application/json" \ + -H "X-Sandbox-Mode: true" \ + -d '{ + "sender": "GABC...", + "recipient": "GDEF...", + "tokenAddress": "CBCD...", + "amount": "10000", + "duration": 86400 + }' + +# Using query parameter +curl -X POST "http://localhost:3001/v1/streams?sandbox=true" \ + -H "Content-Type: application/json" \ + -d '{ + "sender": "GABC...", + "recipient": "GDEF...", + "tokenAddress": "CBCD...", + "amount": "10000", + "duration": 86400 + }' +``` + +### JavaScript/TypeScript Example + +```typescript +// Using fetch with header +const response = await fetch('http://localhost:3001/v1/streams', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sandbox-Mode': 'true', + }, + body: JSON.stringify({ + sender: 'GABC...', + recipient: 'GDEF...', + tokenAddress: 'CBCD...', + amount: '10000', + duration: 86400, + }), +}); + +const data = await response.json(); +console.log('Sandbox mode:', data._sandbox?.mode); // true +``` + +### Checking Sandbox Status + +```bash +# Health endpoint shows sandbox availability +curl http://localhost:3001/health + +# Response includes: +{ + "status": "healthy", + "sandbox": { + "enabled": true, + "available": true + } +} +``` + +## Safety Guarantees + +### Data Isolation + +- ✅ Sandbox data is stored in a separate database +- ✅ Production database is never accessed in sandbox mode +- ✅ No cross-contamination between sandbox and production + +### Response Labeling + +- ✅ All sandbox responses are clearly marked +- ✅ Response headers indicate sandbox mode +- ✅ Response body includes `_sandbox` metadata + +### Error Handling + +If sandbox mode is requested but not enabled: + +```json +{ + "error": "Sandbox mode not available", + "message": "Sandbox mode is not enabled on this server." +} +``` + +If sandbox mode is required but not activated: + +```json +{ + "error": "Sandbox mode required", + "message": "This endpoint requires sandbox mode.", + "hint": { + "header": "X-Sandbox-Mode: true", + "queryParam": "?sandbox=true" + } +} +``` + +## Development Setup + +### Local Development + +1. **Enable sandbox mode** in `.env`: + +```bash +SANDBOX_MODE_ENABLED=true +SANDBOX_DATABASE_URL=file:./sandbox.db +``` + +2. **Run migrations** for sandbox database (if using separate DB): + +```bash +# Sandbox database will be created automatically on first use +# Or run migrations manually if needed +``` + +3. **Start the server**: + +```bash +npm run dev +``` + +4. **Test with sandbox mode**: + +```bash +curl -H "X-Sandbox-Mode: true" http://localhost:3001/v1/streams +``` + +### Production + +**Important:** Sandbox mode should be **disabled** in production: + +```bash +SANDBOX_MODE_ENABLED=false +``` + +Or simply omit the environment variable (defaults to disabled). + +## Best Practices + +### For Developers + +1. **Always test in sandbox first** - Verify functionality before production +2. **Use separate database** - Set `SANDBOX_DATABASE_URL` for complete isolation +3. **Check response headers** - Verify `X-Sandbox-Mode` header in responses +4. **Monitor sandbox data** - Use Prisma Studio or similar to inspect sandbox database + +### For API Consumers + +1. **Check sandbox availability** - Query `/health` endpoint first +2. **Use consistent activation** - Choose header or query param and stick with it +3. **Verify sandbox mode** - Check response headers and `_sandbox` metadata +4. **Don't mix modes** - Don't switch between sandbox and production in the same session + +## Troubleshooting + +### Sandbox Mode Not Working + +1. **Check server configuration**: + ```bash + echo $SANDBOX_MODE_ENABLED # Should be "true" + ``` + +2. **Verify activation method**: + - Header: `X-Sandbox-Mode: true` (case-sensitive) + - Query: `?sandbox=true` (case-sensitive) + +3. **Check response headers**: + ```bash + curl -v -H "X-Sandbox-Mode: true" http://localhost:3001/v1/streams + # Look for: X-Sandbox-Mode: true + ``` + +### Database Issues + +If sandbox database doesn't exist: +- It will be created automatically on first use +- Ensure write permissions in the database directory +- Check `SANDBOX_DATABASE_URL` is correct + +## Related Documentation + +- [API Versioning](./API_VERSIONING.md) +- [Architecture Overview](../../docs/ARCHITECTURE.md) +- [Contributing Guide](../../CONTRIBUTING.md) diff --git a/backend/src/app.ts b/backend/src/app.ts index d5e38d5..efe04cf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,8 +1,9 @@ -import express, { type Request, type Response } from 'express'; +import express, { type Request, type Response, type NextFunction } from 'express'; import cors from 'cors'; import swaggerUi from 'swagger-ui-express'; import { swaggerSpec } from './config/swagger.js'; -import { apiVersionMiddleware } from './middleware/api-version.middleware.js'; +import { apiVersionMiddleware, type VersionedRequest } from './middleware/api-version.middleware.js'; +import { sandboxMiddleware } from './middleware/sandbox.middleware.js'; import { globalRateLimiter } from './middleware/rate-limiter.middleware.js'; import v1Routes from './routes/v1/index.js'; @@ -14,6 +15,9 @@ app.use(globalRateLimiter); app.use(cors()); app.use(express.json()); +// Sandbox mode detection (before versioning) +app.use(sandboxMiddleware); + // Swagger UI setup app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customCss: '.swagger-ui .topbar { display: none }', @@ -31,10 +35,20 @@ app.get('/api-docs.json', (req: Request, res: Response) => { app.use(apiVersionMiddleware); // Versioned API routes -app.use('/v1', v1Routes); +// After versioning middleware, /v1/streams becomes /streams, so we mount v1Routes at root +// But only handle requests that had a version prefix (apiVersion is set) +app.use((req: Request, res: Response, next: NextFunction) => { + const versionedReq = req as VersionedRequest; + if (versionedReq.apiVersion) { + // This was a versioned request, route to v1 handlers + return v1Routes(req, res, next); + } + next(); // Not versioned, continue to deprecated handlers +}); // Legacy routes (deprecated - redirect to v1) // These will be removed in a future version +// Only match unversioned requests app.use('/streams', (req: Request, res: Response, next) => { res.status(410).json({ error: 'Deprecated endpoint', @@ -124,7 +138,10 @@ app.get('/', (req: Request, res: Response) => { * type: string * example: "v1" */ -app.get('/health', (req: Request, res: Response) => { +app.get('/health', async (req: Request, res: Response) => { + const { getSandboxConfig } = await import('./config/sandbox.js'); + const sandboxConfig = getSandboxConfig(); + res.json({ status: 'healthy', timestamp: new Date().toISOString(), @@ -134,6 +151,10 @@ app.get('/health', (req: Request, res: Response) => { supported: ['v1'], default: 'v1', }, + sandbox: { + enabled: sandboxConfig.enabled, + available: sandboxConfig.enabled, + }, }); }); diff --git a/backend/src/config/sandbox.ts b/backend/src/config/sandbox.ts new file mode 100644 index 0000000..263e21f --- /dev/null +++ b/backend/src/config/sandbox.ts @@ -0,0 +1,39 @@ +/** + * Sandbox Mode Configuration + * + * Sandbox mode allows test wallets to interact with a fake or mirrored environment + * without affecting production data. + */ + +export interface SandboxConfig { + enabled: boolean; + databaseUrl?: string; + allowHeader: boolean; + allowQueryParam: boolean; + headerName: string; + queryParamName: string; +} + +/** + * Get sandbox configuration from environment variables + */ +export function getSandboxConfig(): SandboxConfig { + const enabled = process.env.SANDBOX_MODE_ENABLED === 'true'; + const databaseUrl = process.env.SANDBOX_DATABASE_URL; + + return { + enabled, + databaseUrl: databaseUrl || undefined, + allowHeader: process.env.SANDBOX_ALLOW_HEADER !== 'false', // Default: true + allowQueryParam: process.env.SANDBOX_ALLOW_QUERY_PARAM !== 'false', // Default: true + headerName: process.env.SANDBOX_HEADER_NAME || 'X-Sandbox-Mode', + queryParamName: process.env.SANDBOX_QUERY_PARAM_NAME || 'sandbox', + }; +} + +/** + * Check if sandbox mode is globally enabled + */ +export function isSandboxModeEnabled(): boolean { + return getSandboxConfig().enabled; +} diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 3eb8d70..e8b311b 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -6,7 +6,23 @@ const options: swaggerJsdoc.Options = { info: { title: 'FlowFi API', version: '1.0.0', - description: 'API documentation for FlowFi - Real-time payment streaming on Stellar', + description: `API documentation for FlowFi - Real-time payment streaming on Stellar + +## Sandbox Mode + +FlowFi API supports sandbox mode for testing without affecting production data. + +**Enable Sandbox Mode:** +- Header: \`X-Sandbox-Mode: true\` +- Query Parameter: \`?sandbox=true\` + +**Sandbox Features:** +- Isolated database (separate from production) +- All responses include \`_sandbox\` metadata +- Response headers include \`X-Sandbox-Mode: true\` +- Safe for testing and development + +See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, contact: { name: 'FlowFi Team', url: 'https://github.com/LabsCrypt/flowfi', diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 827a63a..250d37b 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -1,13 +1,38 @@ -import type { Request, Response } from 'express'; +import type { Response } from 'express'; import { createStreamSchema } from '../validators/stream.validator.js'; import { sseService } from '../services/sse.service.js'; +import type { SandboxRequest } from '../middleware/sandbox.middleware.js'; +import { isSandboxRequest } from '../middleware/sandbox.middleware.js'; -export const createStream = async (req: Request, res: Response) => { +/** + * Helper to add sandbox metadata to response + */ +function addSandboxMetadata(data: any, isSandbox: boolean): any { + if (!isSandbox) { + return data; + } + + return { + ...data, + _sandbox: { + mode: true, + warning: 'This is sandbox data and does not affect production', + timestamp: new Date().toISOString(), + }, + }; +} + +export const createStream = async (req: SandboxRequest, res: Response) => { try { const validatedData = createStreamSchema.parse(req.body); + const isSandbox = isSandboxRequest(req); - // Mock logging the indexed stream intention - console.log('Indexing new stream intention:', validatedData); + // Log sandbox mode + if (isSandbox) { + console.log('[SANDBOX] Indexing new stream intention:', validatedData); + } else { + console.log('Indexing new stream intention:', validatedData); + } const mockStream = { id: '123', @@ -15,12 +40,13 @@ export const createStream = async (req: Request, res: Response) => { ...validatedData }; - // Broadcast to SSE clients - sseService.broadcastToStream(mockStream.id, 'stream.created', mockStream); - sseService.broadcastToUser(validatedData.sender, 'stream.created', mockStream); - sseService.broadcastToUser(validatedData.recipient, 'stream.created', mockStream); + // Broadcast to SSE clients (sandbox events are also broadcasted but clearly marked) + const streamData = addSandboxMetadata(mockStream, isSandbox); + sseService.broadcastToStream(mockStream.id, 'stream.created', streamData); + sseService.broadcastToUser(validatedData.sender, 'stream.created', streamData); + sseService.broadcastToUser(validatedData.recipient, 'stream.created', streamData); - return res.status(201).json(mockStream); + return res.status(201).json(addSandboxMetadata(mockStream, isSandbox)); } catch (error: any) { if (error.name === 'ZodError' || error.issues) { return res.status(400).json({ diff --git a/backend/src/lib/prisma-sandbox.ts b/backend/src/lib/prisma-sandbox.ts new file mode 100644 index 0000000..382da92 --- /dev/null +++ b/backend/src/lib/prisma-sandbox.ts @@ -0,0 +1,63 @@ +import { PrismaClient } from '../generated/prisma/index.js'; +import { getSandboxConfig } from '../config/sandbox.js'; + +/** + * Sandbox Prisma Client + * + * Uses a separate database connection for sandbox mode to ensure + * complete isolation from production data. + */ + +const globalForSandboxPrisma = globalThis as unknown as { + sandboxPrisma: PrismaClient | undefined; +}; + +/** + * Get sandbox Prisma client instance + * + * If SANDBOX_DATABASE_URL is set, uses that database. + * Otherwise, uses the default DATABASE_URL with a sandbox suffix. + */ +export function getSandboxPrisma(): PrismaClient { + const config = getSandboxConfig(); + + // Use sandbox-specific database URL if provided + const databaseUrl = config.databaseUrl || + (process.env.DATABASE_URL + ? `${process.env.DATABASE_URL}_sandbox` + : 'file:./sandbox.db'); + + if (globalForSandboxPrisma.sandboxPrisma) { + return globalForSandboxPrisma.sandboxPrisma; + } + + const sandboxPrisma = new PrismaClient({ + datasources: { + db: { + url: databaseUrl, + }, + }, + log: process.env.NODE_ENV === 'development' + ? ['query', 'error', 'warn'] + : ['error'], + }); + + if (process.env.NODE_ENV !== 'production') { + globalForSandboxPrisma.sandboxPrisma = sandboxPrisma; + } + + return sandboxPrisma; +} + +/** + * Get the appropriate Prisma client based on sandbox mode + */ +export async function getPrismaClient(isSandbox: boolean): Promise { + if (isSandbox) { + return getSandboxPrisma(); + } + + // Import production prisma client dynamically + const { prisma } = await import('./prisma.js'); + return prisma; +} diff --git a/backend/src/middleware/sandbox.middleware.ts b/backend/src/middleware/sandbox.middleware.ts new file mode 100644 index 0000000..295a139 --- /dev/null +++ b/backend/src/middleware/sandbox.middleware.ts @@ -0,0 +1,101 @@ +import type { Request, Response, NextFunction } from 'express'; +import { getSandboxConfig, isSandboxModeEnabled } from '../config/sandbox.js'; + +/** + * Extended Request interface with sandbox flag + */ +export interface SandboxRequest extends Request { + sandbox?: boolean; + sandboxMode?: boolean; +} + +/** + * Middleware to detect and enable sandbox mode + * + * Sandbox mode can be activated via: + * 1. Header: X-Sandbox-Mode: true + * 2. Query parameter: ?sandbox=true + * + * Sandbox mode must be globally enabled via SANDBOX_MODE_ENABLED=true + */ +export function sandboxMiddleware( + req: SandboxRequest, + res: Response, + next: NextFunction +): void { + const config = getSandboxConfig(); + + // If sandbox mode is not globally enabled, skip + if (!config.enabled) { + req.sandbox = false; + req.sandboxMode = false; + return next(); + } + + let isSandbox = false; + + // Check header + if (config.allowHeader) { + const headerValue = req.headers[config.headerName.toLowerCase()]; + if (headerValue === 'true' || headerValue === '1') { + isSandbox = true; + } + } + + // Check query parameter + if (!isSandbox && config.allowQueryParam) { + const queryValue = req.query[config.queryParamName]; + if (queryValue === 'true' || queryValue === '1') { + isSandbox = true; + } + } + + req.sandbox = isSandbox; + req.sandboxMode = isSandbox; + + // Add sandbox indicator to response headers + if (isSandbox) { + res.setHeader('X-Sandbox-Mode', 'true'); + res.setHeader('X-Environment', 'sandbox'); + } else { + res.setHeader('X-Environment', 'production'); + } + + next(); +} + +/** + * Helper to check if request is in sandbox mode + */ +export function isSandboxRequest(req: SandboxRequest): boolean { + return req.sandbox === true; +} + +/** + * Middleware to require sandbox mode (returns 400 if not in sandbox) + */ +export function requireSandbox( + req: SandboxRequest, + res: Response, + next: NextFunction +): void { + if (!isSandboxModeEnabled()) { + return res.status(503).json({ + error: 'Sandbox mode not available', + message: 'Sandbox mode is not enabled on this server.', + }); + } + + if (!isSandboxRequest(req)) { + return res.status(400).json({ + error: 'Sandbox mode required', + message: 'This endpoint requires sandbox mode. Add X-Sandbox-Mode: true header or ?sandbox=true query parameter.', + hint: { + header: 'X-Sandbox-Mode: true', + queryParam: '?sandbox=true', + }, + }); + } + + next(); +} diff --git a/backend/src/routes/v1/events.routes.ts b/backend/src/routes/v1/events.routes.ts index 503f7af..88b52d5 100644 --- a/backend/src/routes/v1/events.routes.ts +++ b/backend/src/routes/v1/events.routes.ts @@ -26,7 +26,26 @@ const router = Router(); * - `stream.withdrawn` - Funds withdrawn from stream * - `stream.cancelled` - Stream cancelled * - `stream.completed` - Stream completed + * + * **Sandbox Mode:** + * - Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true` + * - Sandbox events are clearly marked with `_sandbox` metadata + * - Sandbox events are isolated from production events * parameters: + * - in: header + * name: X-Sandbox-Mode + * schema: + * type: string + * enum: ["true", "1"] + * description: Enable sandbox mode for testing + * required: false + * - in: query + * name: sandbox + * schema: + * type: string + * enum: ["true", "1"] + * description: Enable sandbox mode via query parameter + * required: false * - in: query * name: streams * schema: diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts index dc3ab2b..fee90e7 100644 --- a/backend/src/routes/v1/stream.routes.ts +++ b/backend/src/routes/v1/stream.routes.ts @@ -13,6 +13,26 @@ const router = Router(); * description: | * Creates a new payment stream. This endpoint indexes the stream intention. * The actual stream creation happens on-chain via Soroban smart contracts. + * + * **Sandbox Mode:** + * - Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true` + * - Sandbox responses include `_sandbox` metadata + * - Sandbox data is stored in a separate database + * parameters: + * - in: header + * name: X-Sandbox-Mode + * schema: + * type: string + * enum: ["true", "1"] + * description: Enable sandbox mode for testing + * required: false + * - in: query + * name: sandbox + * schema: + * type: string + * enum: ["true", "1"] + * description: Enable sandbox mode via query parameter + * required: false * requestBody: * required: true * content: diff --git a/backend/src/validators/stream.validator.ts b/backend/src/validators/stream.validator.ts index 078781e..0e6d6cd 100644 --- a/backend/src/validators/stream.validator.ts +++ b/backend/src/validators/stream.validator.ts @@ -3,10 +3,9 @@ import { z } from 'zod'; export const createStreamSchema = z.object({ sender: z.string().min(1, 'Sender address is required'), recipient: z.string().min(1, 'Recipient address is required'), - amount: z.number().positive('Amount must be positive'), - token: z.string().min(1, 'Token is required'), - startTime: z.number().optional(), - endTime: z.number().optional(), + tokenAddress: z.string().min(1, 'Token address is required'), + amount: z.string().regex(/^\d+$/, 'Amount must be a positive integer as string'), + duration: z.number().int().positive('Duration must be a positive integer in seconds'), }); export type CreateStreamInput = z.infer;