diff --git a/backend/package.json b/backend/package.json index 2426ecc5..960dda00 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,8 @@ "test:smoke": "jest -c tests/smoke/jest.smoke.config.js", "test:smoke:verbose": "VERBOSE=1 jest -c tests/smoke/jest.smoke.config.js --verbose", "setup:smoke-user": "ts-node ../scripts/setup-smoke-test-user.ts", - "swagger:export": "ts-node scripts/export-swagger.ts", + "swagger:export": "npm run openapi:generate && ts-node scripts/export-swagger.ts", + "openapi:generate": "ts-node scripts/generate-openapi-paths.ts", "db:migrate": "supabase db push", "db:migrate:prod": "supabase db push --db-url \"$PRODUCTION_DB_URL\"", "db:reset": "supabase db reset", @@ -65,6 +66,7 @@ "rate-limit-redis": "^4.3.1", "redis": "^6.0.0", "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "telegraf": "^4.16.3", "uuid": "^14.0.1", "web-push": "^3.6.7", diff --git a/backend/scripts/generate-openapi-paths.ts b/backend/scripts/generate-openapi-paths.ts new file mode 100644 index 00000000..eb3dcfc6 --- /dev/null +++ b/backend/scripts/generate-openapi-paths.ts @@ -0,0 +1,10 @@ +/** + * Generates OpenAPI path JSDoc blocks from route comments and registrations. + * Run with: npm run openapi:generate + */ +import * as path from 'path'; +import { generateOpenApiPathsFile } from '../src/openapi/route-generator'; + +const outputPath = path.join(__dirname, '..', 'src', 'openapi', 'generated-paths.ts'); +const count = generateOpenApiPathsFile(outputPath); +console.log(`Generated ${count} OpenAPI paths at ${outputPath}`); diff --git a/backend/src/index.ts b/backend/src/index.ts index 577dafb6..d4482617 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -225,6 +225,12 @@ app.get('/health', async (req, res) => { }); // Swagger Documentation +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +app.get('/api-docs.json', (_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); +}); +// Legacy aliases app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.get('/api/docs.json', (_req, res) => { res.setHeader('Content-Type', 'application/json'); diff --git a/backend/src/openapi/route-generator.ts b/backend/src/openapi/route-generator.ts new file mode 100644 index 00000000..74d9e295 --- /dev/null +++ b/backend/src/openapi/route-generator.ts @@ -0,0 +1,403 @@ +/** + * Scans Express route files and index.ts to emit OpenAPI path JSDoc blocks + * from route comment annotations and router method registrations. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface RouteDefinition { + method: string; + path: string; + summary: string; + tag: string; + security: 'public' | 'user' | 'admin'; + x402?: boolean; +} + +const ROUTES_DIR = path.join(__dirname, '..', 'routes'); +const INDEX_FILE = path.join(__dirname, '..', 'index.ts'); + +const TAG_BY_PREFIX: Record = { + '/api/subscriptions': 'Subscriptions', + '/api/analytics': 'Analytics', + '/api/tags': 'Tags', + '/api/user': 'User', + '/api/user-preferences': 'User Preferences', + '/api/mfa': 'MFA', + '/api/keys': 'API Keys', + '/api/team': 'Team', + '/api/digest': 'Digest', + '/api/notifications/push': 'Push Notifications', + '/api/risk-score': 'Risk Score', + '/api/simulation': 'Simulation', + '/api/exchange-rates': 'Exchange Rates', + '/api/calendar': 'Calendar', + '/api/gift-card-ledger': 'Gift Card Ledger', + '/api/referrals': 'Referrals', + '/api/suggestions': 'Suggestions', + '/api/merchants': 'Merchants', + '/api/audit': 'Audit', + '/api/compliance': 'Compliance', + '/api/webhooks': 'Webhooks', + '/api/reminder-settings': 'Reminder Settings', + '/api/integrations/gmail': 'Gmail Integration', + '/api/integrations/outlook': 'Outlook Integration', + '/api/integrations/yahoo': 'Yahoo Integration', + '/api/integrations/icloud': 'iCloud Integration', + '/api/integrations/slack': 'Slack Integration', + '/api/integrations/email': 'Email Integration', + '/api/telegram': 'Telegram', + '/api/admin': 'Admin', + '/api/reminders': 'Reminders', + '/api/payments': 'Payments', + '/api/payment-channels': 'Payment Channels', + '/api/wallet': 'Wallet', + '/api/key-rotation': 'Key Rotation', + '/api/privacy': 'Privacy', + '/api/notifications/dead-letter': 'Notification Dead Letter', + '/api/renewals/dead-letter': 'Renewal Dead Letter', + '/api/csp-violations': 'CSP Violations', + '/api/webhooks/paystack': 'Paystack Webhook', +}; + +const X402_PREFIXES = ['/api/payments', '/api/payment-channels', '/api/wallet']; + +function tagForPath(fullPath: string): string { + const sorted = Object.keys(TAG_BY_PREFIX).sort((a, b) => b.length - a.length); + for (const prefix of sorted) { + if (fullPath.startsWith(prefix)) { + return TAG_BY_PREFIX[prefix]; + } + } + if (fullPath.startsWith('/health')) return 'Health'; + return 'API'; +} + +function isX402Path(fullPath: string): boolean { + return X402_PREFIXES.some((prefix) => fullPath.startsWith(prefix)); +} + +function normalizePath(routePath: string): string { + return routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); +} + +function joinPaths(mount: string, routePath: string): string { + if (routePath === '/') return mount; + const base = mount.endsWith('/') ? mount.slice(0, -1) : mount; + const suffix = routePath.startsWith('/') ? routePath : `/${routePath}`; + return `${base}${suffix}`; +} + +function parseMountPoints(indexSource: string): Map { + const mounts = new Map(); + const importRegex = /import\s+(?:(\w+)|\{([^}]+)\})\s+from\s+['"]\.\/routes\/([^'"]+)['"]/g; + const importMap = new Map(); + + let match: RegExpExecArray | null; + while ((match = importRegex.exec(indexSource)) !== null) { + const defaultImport = match[1]; + const namedImports = match[2]; + const filePath = match[3]; + if (defaultImport) { + importMap.set(defaultImport, filePath); + } + if (namedImports) { + for (const part of namedImports.split(',')) { + const trimmed = part.trim(); + const aliasMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/); + if (aliasMatch) { + importMap.set(aliasMatch[2], filePath); + } else { + importMap.set(trimmed.replace(/\s/g, ''), filePath); + } + } + } + } + + const useLineRegex = /app\.use\(\s*['"]([^'"]+)['"]\s*,\s*([\s\S]*?)\s*\)\s*;?/g; + while ((match = useLineRegex.exec(indexSource)) !== null) { + const mount = match[1]; + if (!mount.startsWith('/api/')) continue; + + const args = match[2]; + const routerMatch = args.match(/(\w+Routes|\w+Router)\s*\)?\s*$/); + if (!routerMatch) continue; + + const routerVar = routerMatch[1]; + const filePath = importMap.get(routerVar); + if (!filePath) continue; + + let auth: 'public' | 'user' | 'admin' = 'public'; + if (args.includes('adminAuth') || mount.includes('/admin/')) auth = 'admin'; + else if (args.includes('authenticate')) auth = 'user'; + + mounts.set(filePath.replace(/\.ts$/, ''), { mount, auth }); + } + + return mounts; +} + +function parseCommentRoutes(source: string): RouteDefinition[] { + const routes: RouteDefinition[] = []; + const commentRegex = /\/\*\*\s*\n(?: \*[^\n]*\n)*? \*\//g; + const methodLineRegex = /^\s*\*\s*(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/m; + const descLineRegex = /^\s*\*\s*(?!@|GET |POST |PUT |PATCH |DELETE )(.+)/m; + + let block: RegExpExecArray | null; + while ((block = commentRegex.exec(source)) !== null) { + const text = block[0]; + if (text.includes('@swagger') || text.includes('@openapi')) continue; + if (text.includes('Routes:') || text.includes('Issue #')) continue; + + const methodMatch = methodLineRegex.exec(text); + if (!methodMatch) continue; + + const method = methodMatch[1].toLowerCase(); + const routePath = methodMatch[2]; + const descMatch = descLineRegex.exec(text.slice(methodMatch.index + methodMatch[0].length)); + const summary = descMatch ? descMatch[1].trim() : `${method.toUpperCase()} ${routePath}`; + + routes.push({ + method, + path: normalizePath(routePath), + summary, + tag: tagForPath(routePath), + security: routePath.startsWith('/api/admin') ? 'admin' : 'user', + x402: isX402Path(routePath), + }); + } + + return routes; +} + +function parseRouterMethods( + source: string, + mount: string, + defaultAuth: 'public' | 'user' | 'admin', +): RouteDefinition[] { + const routes: RouteDefinition[] = []; + const routerRegex = /router\.(get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + + while ((match = routerRegex.exec(source)) !== null) { + const method = match[1].toLowerCase(); + const routePath = match[2]; + const fullPath = normalizePath(joinPaths(mount, routePath)); + + routes.push({ + method, + path: fullPath, + summary: `${method.toUpperCase()} ${fullPath}`, + tag: tagForPath(fullPath), + security: source.includes('router.use(adminAuth)') || fullPath.includes('/admin/') + ? 'admin' + : source.includes('router.use(authenticate)') || defaultAuth === 'user' + ? 'user' + : defaultAuth, + x402: isX402Path(fullPath), + }); + } + + return routes; +} + +function parseInlineIndexRoutes(indexSource: string): RouteDefinition[] { + const routes: RouteDefinition[] = []; + const inlineRegex = /app\.(get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + + while ((match = inlineRegex.exec(indexSource)) !== null) { + const method = match[1].toLowerCase(); + const routePath = match[2]; + if (routePath.startsWith('/api/docs') || routePath.startsWith('/api-docs')) continue; + + const segment = indexSource.slice(match.index, match.index + 400); + let security: RouteDefinition['security'] = 'public'; + if (segment.includes('adminAuth')) security = 'admin'; + else if (segment.includes('authenticate')) security = 'user'; + + routes.push({ + method, + path: normalizePath(routePath), + summary: `${method.toUpperCase()} ${routePath}`, + tag: tagForPath(routePath), + security, + x402: isX402Path(routePath), + }); + } + + return routes; +} + +function dedupeRoutes(routes: RouteDefinition[]): RouteDefinition[] { + const byKey = new Map(); + + for (const route of routes) { + const key = `${route.method}:${route.path}`; + const existing = byKey.get(key); + if (!existing) { + byKey.set(key, route); + continue; + } + + const existingIsGeneric = existing.summary.startsWith(existing.method.toUpperCase()); + const incomingIsGeneric = route.summary.startsWith(route.method.toUpperCase()); + if (existingIsGeneric && !incomingIsGeneric) { + byKey.set(key, route); + } + } + + return [...byKey.values()].sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); +} + +function collectRouteFiles(dir: string, files: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectRouteFiles(full, files); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + files.push(full); + } + } + return files; +} + +export function collectRouteDefinitions(): RouteDefinition[] { + const indexSource = fs.readFileSync(INDEX_FILE, 'utf8'); + const mounts = parseMountPoints(indexSource); + const routes: RouteDefinition[] = [...parseInlineIndexRoutes(indexSource)]; + + for (const filePath of collectRouteFiles(ROUTES_DIR)) { + const rel = path.relative(path.join(__dirname, '..'), filePath).replace(/\\/g, '/').replace(/\.ts$/, ''); + const relKey = rel.replace(/^routes\//, ''); + const source = fs.readFileSync(filePath, 'utf8'); + + routes.push(...parseCommentRoutes(source)); + + const mountInfo = mounts.get(relKey); + if (mountInfo) { + routes.push(...parseRouterMethods(source, mountInfo.mount, mountInfo.auth)); + } + } + + return dedupeRoutes(routes); +} + +function sanitizeSummary(summary: string): string { + const cleaned = summary + .replace(/\s+/g, ' ') + .replace(/"/g, '\\"') + .trim(); + if (/[:#{}[\]|>&*!?@`]/.test(cleaned) || cleaned.startsWith('-')) { + return `"${cleaned}"`; + } + return cleaned; +} + +function securityBlock(security: RouteDefinition['security']): string { + if (security === 'admin') { + return ` * security: + * - adminKey: []`; + } + if (security === 'user') { + return ` * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * - cookieAuth: []`; + } + return ''; +} + +function x402Parameters(route: RouteDefinition): string { + if (!route.x402) return ''; + if (route.method === 'post' || route.method === 'put' || route.method === 'patch') { + return ` * parameters: + * - $ref: '#/components/parameters/PaymentSignatureHeader' +`; + } + return ''; +} + +function x402Responses(route: RouteDefinition): string { + if (!route.x402) return ''; + return ` * 402: + * description: Payment required (x402) + * headers: + * PAYMENT-REQUIRED: + * $ref: '#/components/headers/PaymentRequired' + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/X402PaymentRequired' + * example: + * x402Version: 2 + * error: "PAYMENT-SIGNATURE header is required" + * accepts: + * - scheme: exact + * network: "eip155:84532" + * amount: "10000" + * asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + * payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C" + * maxTimeoutSeconds: 60 +`; +} + +export function renderOpenApiPaths(routes: RouteDefinition[]): string { + const blocks = routes.map((route) => { + const security = securityBlock(route.security); + const x402Params = x402Parameters(route); + const x402Resp = x402Responses(route); + const x402Ext = route.x402 + ? ` * x-x402-payment: + * enabled: true + * description: Supports HTTP 402 micropayments via PAYMENT-SIGNATURE header +` + : ''; + + return `/** + * @openapi + * ${route.path}: + * ${route.method}: + * summary: ${sanitizeSummary(route.summary)} + * tags: [${route.tag}] +${x402Ext}${security} +${x402Params} * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * example: + * success: true + * data: {} +${x402Resp} * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */`; + }); + + return `/** + * AUTO-GENERATED FILE — do not edit manually. + * Regenerate with: npm run openapi:generate + * + * OpenAPI path definitions generated from route comments and router registrations. + */ +${blocks.join('\n\n')} +`; +} + +export function generateOpenApiPathsFile(outputPath: string): number { + const routes = collectRouteDefinitions(); + fs.writeFileSync(outputPath, renderOpenApiPaths(routes)); + return routes.length; +} diff --git a/backend/src/openapi/x402-docs.ts b/backend/src/openapi/x402-docs.ts new file mode 100644 index 00000000..f0d03405 --- /dev/null +++ b/backend/src/openapi/x402-docs.ts @@ -0,0 +1,120 @@ +/** + * @openapi + * components: + * headers: + * PaymentRequired: + * description: | + * x402 V2 server header. Base64-encoded JSON `PaymentRequired` object returned + * with HTTP 402 responses describing accepted payment schemes. + * schema: + * type: string + * example: eyJ4NDAyVmVyc2lvbiI6Mn0= + * PaymentSignature: + * description: | + * x402 V2 client header. Base64-encoded JSON `PaymentPayload` proving payment + * authorization. Retried on the original request after receiving 402. + * schema: + * type: string + * example: eyJ4NDAyVmVyc2lvbiI6Mn0= + * PaymentResponse: + * description: | + * x402 V2 settlement header. Base64-encoded JSON `SettlementResponse` describing + * on-chain settlement outcome after payment verification. + * schema: + * type: string + * example: eyJzdWNjZXNzIjp0cnVlfQ== + * parameters: + * PaymentSignatureHeader: + * name: PAYMENT-SIGNATURE + * in: header + * required: false + * description: | + * x402 payment authorization header (Base64-encoded PaymentPayload JSON). + * Required when retrying a request after receiving HTTP 402 with PAYMENT-REQUIRED. + * schema: + * type: string + * example: eyJ4NDAyVmVyc2lvbiI6MiwicGF5bG9hZCI6e319 + * PaymentRequiredHeader: + * name: PAYMENT-REQUIRED + * in: header + * required: false + * description: | + * x402 payment requirements header (Base64-encoded PaymentRequired JSON). + * Returned by the server on HTTP 402 responses. + * schema: + * type: string + * schemas: + * X402PaymentRequired: + * type: object + * description: Decoded PAYMENT-REQUIRED header payload (x402 V2) + * properties: + * x402Version: + * type: integer + * example: 2 + * error: + * type: string + * example: PAYMENT-SIGNATURE header is required + * resource: + * type: object + * properties: + * url: + * type: string + * format: uri + * example: https://api.syncro.app/api/payments/paystack/initialize + * description: + * type: string + * example: Initialize wallet funding transaction + * mimeType: + * type: string + * example: application/json + * accepts: + * type: array + * items: + * $ref: '#/components/schemas/X402PaymentRequirements' + * X402PaymentRequirements: + * type: object + * properties: + * scheme: + * type: string + * example: exact + * network: + * type: string + * example: eip155:84532 + * amount: + * type: string + * example: "10000" + * asset: + * type: string + * example: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + * payTo: + * type: string + * example: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C" + * maxTimeoutSeconds: + * type: integer + * example: 60 + * X402PaymentPayload: + * type: object + * description: Decoded PAYMENT-SIGNATURE header payload (x402 V2) + * properties: + * x402Version: + * type: integer + * example: 2 + * accepted: + * $ref: '#/components/schemas/X402PaymentRequirements' + * payload: + * type: object + * description: Scheme-specific signed payment data + * X402SettlementResponse: + * type: object + * description: Decoded PAYMENT-RESPONSE header payload (x402 V2) + * properties: + * success: + * type: boolean + * example: true + * transaction: + * type: string + * example: "0xabc123..." + * network: + * type: string + * example: eip155:84532 + */ diff --git a/backend/src/routes/agent-wallets.ts b/backend/src/routes/agent-wallets.ts index c147e7c2..493e92fc 100644 --- a/backend/src/routes/agent-wallets.ts +++ b/backend/src/routes/agent-wallets.ts @@ -35,7 +35,7 @@ router.use(adminAuth); * summary: List rotation state for all pipeline agents * tags: [AgentWallets] * security: - * - AdminApiKey: [] + * - adminKey: [] * responses: * 200: * description: Current rotation states @@ -58,7 +58,7 @@ router.get('/', async (_req: Request, res: Response) => { * summary: Get rotation state and address history for a single agent * tags: [AgentWallets] * security: - * - AdminApiKey: [] + * - adminKey: [] * parameters: * - in: path * name: agent @@ -98,7 +98,7 @@ router.get('/:agent', async (req: Request, res: Response) => { * summary: Force-rotate all pipeline agent wallets immediately * tags: [AgentWallets] * security: - * - AdminApiKey: [] + * - adminKey: [] * requestBody: * required: false * content: @@ -133,7 +133,7 @@ router.post('/rotate', async (req: Request, res: Response) => { * summary: Force-rotate a single pipeline agent wallet * tags: [AgentWallets] * security: - * - AdminApiKey: [] + * - adminKey: [] * parameters: * - in: path * name: agent diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index f5045167..e78b59ca 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1,16 +1,41 @@ import swaggerJSDoc from 'swagger-jsdoc'; -import type { OpenAPIV3 } from 'openapi-types'; +import type { OpenAPIV3_1 } from 'openapi-types'; const options: swaggerJSDoc.Options = { definition: { - openapi: '3.0.0', + openapi: '3.1.0', info: { title: 'SYNCRO API', version: '1.0.0', - description: 'Self-custodial subscription management platform API', + description: [ + 'Self-custodial subscription management platform API.', + '', + '## Authentication', + '', + 'Most endpoints require one of:', + '- **Bearer token** — `Authorization: Bearer `', + '- **API key** — `x-api-key: sk_...` (created via `/api/keys`)', + '- **Session cookie** — `authToken` HTTP-only cookie', + '', + 'Admin endpoints require `X-Admin-API-Key`.', + '', + '## x402 Micropayments', + '', + 'Payment endpoints support the [x402 protocol](https://docs.x402.org/) via HTTP 402 and', + 'the `PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE`, and `PAYMENT-RESPONSE` headers.', + ].join('\n'), }, servers: [ { url: 'http://localhost:3001', description: 'Development server' }, + { url: 'https://api.syncro.app', description: 'Production server' }, + ], + tags: [ + { name: 'Health', description: 'Liveness and readiness probes' }, + { name: 'Subscriptions', description: 'Subscription CRUD and lifecycle' }, + { name: 'Analytics', description: 'Spend analytics and forecasting' }, + { name: 'API Keys', description: 'Programmatic API key management' }, + { name: 'Payments', description: 'Wallet funding and payment channels (x402-capable)' }, + { name: 'Admin', description: 'Admin-only monitoring and operations' }, ], components: { securitySchemes: { @@ -20,6 +45,12 @@ const options: swaggerJSDoc.Options = { bearerFormat: 'JWT', description: 'Supabase JWT token via Authorization: Bearer ', }, + apiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'x-api-key', + description: 'API key prefixed with sk_ (POST /api/keys to create)', + }, cookieAuth: { type: 'apiKey', in: 'cookie', @@ -37,18 +68,19 @@ const options: swaggerJSDoc.Options = { SuccessResponse: { type: 'object', properties: { - success: { type: 'boolean', example: true }, + success: { type: 'boolean', examples: [true] }, + data: { type: 'object' }, }, }, ProblemDetails: { type: 'object', properties: { - type: { type: 'string', format: 'uri', example: 'https://syncro.app/errors/not-found' }, - title: { type: 'string', example: 'Not Found' }, - status: { type: 'integer', example: 404 }, - detail: { type: 'string', example: 'Subscription with ID 123 not found.' }, - instance: { type: 'string', example: '/api/v1/subscriptions/123' }, - requestId: { type: 'string', example: 'req-abc-123' }, + type: { type: 'string', format: 'uri', examples: ['https://syncro.app/errors/not-found'] }, + title: { type: 'string', examples: ['Not Found'] }, + status: { type: 'integer', examples: [404] }, + detail: { type: 'string', examples: ['Subscription with ID 123 not found.'] }, + instance: { type: 'string', examples: ['/api/subscriptions/123'] }, + requestId: { type: 'string', examples: ['req-abc-123'] }, errors: { type: 'array', items: { @@ -67,9 +99,53 @@ const options: swaggerJSDoc.Options = { Pagination: { type: 'object', properties: { - total: { type: 'integer' }, - limit: { type: 'integer' }, - offset: { type: 'integer' }, + total: { type: 'integer', examples: [42] }, + limit: { type: 'integer', examples: [20] }, + offset: { type: 'integer', examples: [0] }, + hasMore: { type: 'boolean', examples: [false] }, + nextCursor: { type: 'string', nullable: true, examples: [null] }, + }, + }, + CreateSubscriptionRequest: { + type: 'object', + required: ['name', 'price', 'billing_cycle'], + properties: { + name: { type: 'string', minLength: 1, examples: ['Netflix'] }, + price: { type: 'number', minimum: 0, examples: [15.99] }, + billing_cycle: { + type: 'string', + enum: ['monthly', 'yearly', 'quarterly', 'weekly', 'annual'], + examples: ['monthly'], + }, + currency: { type: 'string', examples: ['USD'] }, + renewal_url: { type: 'string', format: 'uri', examples: ['https://netflix.com/account'] }, + website_url: { type: 'string', format: 'uri', examples: ['https://netflix.com'] }, + category: { type: 'string', examples: ['Entertainment'] }, + }, + }, + CreateApiKeyRequest: { + type: 'object', + required: ['scopes'], + properties: { + name: { type: 'string', examples: ['CI integration'] }, + scopes: { + type: 'array', + items: { + type: 'string', + enum: ['subscriptions:read', 'subscriptions:write', 'webhooks:write', 'analytics:read'], + }, + examples: [['subscriptions:read', 'subscriptions:write']], + }, + }, + }, + ApiKeyResponse: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', examples: ['CI integration'] }, + key: { type: 'string', examples: ['sk_live_a1b2c3d4e5f67890'] }, + scopes: { type: 'array', items: { type: 'string' } }, + created_at: { type: 'string', format: 'date-time' }, }, }, Subscription: { @@ -77,8 +153,8 @@ const options: swaggerJSDoc.Options = { properties: { id: { type: 'string', format: 'uuid' }, user_id: { type: 'string', format: 'uuid' }, - name: { type: 'string', example: 'Netflix' }, - price: { type: 'number', example: 15.99 }, + name: { type: 'string', examples: ['Netflix'] }, + price: { type: 'number', examples: [15.99] }, billing_cycle: { type: 'string', enum: ['monthly', 'yearly', 'quarterly'] }, status: { type: 'string', enum: ['active', 'cancelled', 'expired'] }, renewal_url: { type: 'string', format: 'uri', nullable: true }, @@ -137,34 +213,39 @@ const options: swaggerJSDoc.Options = { MonthlySpend: { type: 'object', properties: { - month: { type: 'string', example: '2026-05', description: 'YYYY-MM' }, - total_spend: { type: 'number', example: 89.97 }, - count: { type: 'integer', example: 5 }, + month: { type: 'string', examples: ['2026-05'], description: 'YYYY-MM' }, + total_spend: { type: 'number', examples: [89.97] }, + count: { type: 'integer', examples: [5] }, }, }, CategorySpend: { type: 'object', properties: { - category: { type: 'string', example: 'Entertainment' }, - total_spend: { type: 'number', example: 45.98 }, - percentage: { type: 'number', example: 51.1 }, - count: { type: 'integer', example: 3 }, + category: { type: 'string', examples: ['Entertainment'] }, + total_spend: { type: 'number', examples: [45.98] }, + percentage: { type: 'number', examples: [51.1] }, + count: { type: 'integer', examples: [3] }, }, }, SubscriptionSpend: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, - name: { type: 'string', example: 'Netflix' }, - price: { type: 'number', example: 15.99 }, - billing_cycle: { type: 'string', example: 'monthly' }, - monthly_normalized_price: { type: 'number', example: 15.99 }, + name: { type: 'string', examples: ['Netflix'] }, + price: { type: 'number', examples: [15.99] }, + billing_cycle: { type: 'string', examples: ['monthly'] }, + monthly_normalized_price: { type: 'number', examples: [15.99] }, }, }, }, }, }, - apis: ['./src/routes/**/*.ts', './src/index.ts'], + apis: [ + './src/openapi/generated-paths.ts', + './src/openapi/x402-docs.ts', + './src/routes/**/*.ts', + './src/index.ts', + ], }; -export const swaggerSpec = swaggerJSDoc(options) as unknown as OpenAPIV3.Document; +export const swaggerSpec = swaggerJSDoc(options) as unknown as OpenAPIV3_1.Document; diff --git a/backend/tests/openapi-generator.test.ts b/backend/tests/openapi-generator.test.ts new file mode 100644 index 00000000..f3facd5b --- /dev/null +++ b/backend/tests/openapi-generator.test.ts @@ -0,0 +1,38 @@ +import { collectRouteDefinitions, generateOpenApiPathsFile } from '../src/openapi/route-generator'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +describe('openapi route generator', () => { + test('collects routes from route files and index.ts', () => { + const routes = collectRouteDefinitions(); + expect(routes.length).toBeGreaterThan(150); + + const subscriptionList = routes.find( + (route) => route.method === 'get' && route.path === '/api/subscriptions', + ); + expect(subscriptionList).toBeDefined(); + expect(subscriptionList?.summary).toMatch(/subscription/i); + }); + + test('marks payment routes as x402-capable', () => { + const routes = collectRouteDefinitions(); + const paystackInit = routes.find( + (route) => route.path === '/api/payments/paystack/initialize' && route.method === 'post', + ); + expect(paystackInit?.x402).toBe(true); + }); + + test('generates valid OpenAPI JSDoc output', () => { + const tmpFile = path.join(os.tmpdir(), `syncro-openapi-${Date.now()}.ts`); + const count = generateOpenApiPathsFile(tmpFile); + const content = fs.readFileSync(tmpFile, 'utf8'); + + expect(count).toBeGreaterThan(150); + expect(content).toContain('@openapi'); + expect(content).toContain('PAYMENT-SIGNATURE'); + expect(content).not.toContain("PaymentSignatureHeader' * responses:"); + + fs.unlinkSync(tmpFile); + }); +}); diff --git a/backend/tests/swagger.test.ts b/backend/tests/swagger.test.ts index c4d3bb52..22d14eed 100644 --- a/backend/tests/swagger.test.ts +++ b/backend/tests/swagger.test.ts @@ -1,11 +1,46 @@ import { swaggerSpec } from '../src/swagger'; describe('swagger spec', () => { - test('has OpenAPI version and basic info', () => { + test('has OpenAPI 3.1 version and basic info', () => { expect(swaggerSpec).toBeDefined(); - expect(swaggerSpec.openapi).toBe('3.0.0'); + expect(swaggerSpec.openapi).toBe('3.1.0'); expect(swaggerSpec.info).toBeDefined(); expect(swaggerSpec.info.title).toMatch(/SYNCRO/i); expect(swaggerSpec.info.version).toBeDefined(); }); + + test('documents authentication schemes', () => { + const schemes = swaggerSpec.components?.securitySchemes ?? {}; + expect(schemes).toHaveProperty('bearerAuth'); + expect(schemes).toHaveProperty('apiKeyAuth'); + expect(schemes).toHaveProperty('cookieAuth'); + expect(schemes).toHaveProperty('adminKey'); + expect(schemes.apiKeyAuth).toMatchObject({ + type: 'apiKey', + in: 'header', + name: 'x-api-key', + }); + }); + + test('documents x402 payment headers', () => { + expect(swaggerSpec.components?.parameters?.PaymentSignatureHeader).toBeDefined(); + expect(swaggerSpec.components?.headers?.PaymentRequired).toBeDefined(); + expect(swaggerSpec.components?.schemas?.X402PaymentRequired).toBeDefined(); + }); + + test('covers API routes comprehensively', () => { + const paths = Object.keys(swaggerSpec.paths ?? {}); + expect(paths.length).toBeGreaterThanOrEqual(150); + expect(paths).toEqual(expect.arrayContaining([ + '/api/subscriptions', + '/api/keys', + '/api/payments/paystack/initialize', + '/health', + ])); + }); + + test('includes request schema examples', () => { + expect(swaggerSpec.components?.schemas?.CreateSubscriptionRequest).toBeDefined(); + expect(swaggerSpec.components?.schemas?.CreateApiKeyRequest).toBeDefined(); + }); }); diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json deleted file mode 100644 index 90639dad..00000000 --- a/docs/api-reference/openapi.json +++ /dev/null @@ -1,752 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "SYNCRO API", - "version": "1.0.0", - "description": "Self-custodial subscription management platform API" - }, - "servers": [ - { - "url": "http://localhost:3001", - "description": "Development server" - } - ], - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Supabase JWT token via Authorization: Bearer " - }, - "cookieAuth": { - "type": "apiKey", - "in": "cookie", - "name": "authToken", - "description": "HTTP-only cookie auth (alternative to Bearer)" - }, - "adminKey": { - "type": "apiKey", - "in": "header", - "name": "X-Admin-API-Key", - "description": "Admin API key for protected admin endpoints" - } - }, - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - }, - "ProblemDetails": { - "type": "object", - "properties": { - "type": { - "type": "string", - "format": "uri", - "example": "https://syncro.app/errors/not-found" - }, - "title": { - "type": "string", - "example": "Not Found" - }, - "status": { - "type": "integer", - "example": 404 - }, - "detail": { - "type": "string", - "example": "Subscription with ID 123 not found." - }, - "instance": { - "type": "string", - "example": "/api/v1/subscriptions/123" - }, - "requestId": { - "type": "string", - "example": "req-abc-123" - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "ErrorResponse": { - "$ref": "#/components/schemas/ProblemDetails" - }, - "Pagination": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "limit": { - "type": "integer" - }, - "offset": { - "type": "integer" - } - } - }, - "Subscription": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "user_id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "example": "Netflix" - }, - "price": { - "type": "number", - "example": 15.99 - }, - "billing_cycle": { - "type": "string", - "enum": [ - "monthly", - "yearly", - "quarterly" - ] - }, - "status": { - "type": "string", - "enum": [ - "active", - "cancelled", - "expired" - ] - }, - "renewal_url": { - "type": "string", - "format": "uri", - "nullable": true - }, - "website_url": { - "type": "string", - "format": "uri", - "nullable": true - }, - "logo_url": { - "type": "string", - "format": "uri", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "BlockchainResult": { - "type": "object", - "properties": { - "synced": { - "type": "boolean" - }, - "transactionHash": { - "type": "string", - "nullable": true - }, - "error": { - "type": "string", - "nullable": true - } - } - }, - "RiskScore": { - "type": "object", - "properties": { - "subscription_id": { - "type": "string", - "format": "uuid" - }, - "risk_level": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ] - }, - "risk_factors": { - "type": "array", - "items": { - "type": "object" - } - }, - "last_calculated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "Merchant": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "category": { - "type": "string", - "nullable": true - }, - "website_url": { - "type": "string", - "format": "uri", - "nullable": true - }, - "logo_url": { - "type": "string", - "format": "uri", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "TeamMember": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "userId": { - "type": "string", - "format": "uuid" - }, - "email": { - "type": "string", - "format": "email", - "nullable": true - }, - "role": { - "type": "string", - "enum": [ - "admin", - "member", - "viewer" - ] - }, - "joinedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "DigestPreferences": { - "type": "object", - "properties": { - "digestEnabled": { - "type": "boolean" - }, - "digestDay": { - "type": "integer", - "minimum": 1, - "maximum": 28 - }, - "includeYearToDate": { - "type": "boolean" - } - } - }, - "MonthlySpend": { - "type": "object", - "properties": { - "month": { - "type": "string", - "example": "2026-05", - "description": "YYYY-MM" - }, - "total_spend": { - "type": "number", - "example": 89.97 - }, - "count": { - "type": "integer", - "example": 5 - } - } - }, - "CategorySpend": { - "type": "object", - "properties": { - "category": { - "type": "string", - "example": "Entertainment" - }, - "total_spend": { - "type": "number", - "example": 45.98 - }, - "percentage": { - "type": "number", - "example": 51.1 - }, - "count": { - "type": "integer", - "example": 3 - } - } - }, - "SubscriptionSpend": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string", - "example": "Netflix" - }, - "price": { - "type": "number", - "example": 15.99 - }, - "billing_cycle": { - "type": "string", - "example": "monthly" - }, - "monthly_normalized_price": { - "type": "number", - "example": 15.99 - } - } - } - } - }, - "paths": { - "/api/risk-score/recalculate": { - "post": { - "tags": [ - "Risk Score" - ], - "summary": "Trigger risk recalculation for all subscriptions", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Recalculation result" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden - Admin access required" - } - } - } - }, - "/api/notifications/push/vapid-public-key": { - "get": { - "tags": [ - "Push Notifications" - ], - "summary": "Get the VAPID public key for push subscriptions", - "responses": { - "200": { - "description": "VAPID public key", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "publicKey": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/api/analytics/summary": { - "get": { - "summary": "Get spend analytics summary and trends", - "tags": [ - "Analytics" - ], - "security": [ - { - "bearerAuth": [] - }, - { - "cookieAuth": [] - } - ], - "responses": { - "200": { - "description": "Analytics summary", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "total_monthly_spend": { - "type": "number", - "example": 89.97 - }, - "active_subscriptions": { - "type": "integer", - "example": 5 - }, - "upcoming_renewals_count": { - "type": "integer", - "example": 2 - }, - "monthly_trend": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MonthlySpend" - } - }, - "category_breakdown": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategorySpend" - } - }, - "top_subscriptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubscriptionSpend" - } - }, - "budget_status": { - "type": "object", - "properties": { - "overall_limit": { - "type": "number", - "nullable": true - }, - "current_spend": { - "type": "number" - }, - "percentage": { - "type": "number" - } - } - } - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/analytics/budgets": { - "get": { - "summary": "Get user monthly budgets", - "tags": [ - "Analytics" - ], - "security": [ - { - "bearerAuth": [] - }, - { - "cookieAuth": [] - } - ], - "responses": { - "200": { - "description": "List of budgets for the authenticated user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "user_id": { - "type": "string", - "format": "uuid" - }, - "category": { - "type": "string", - "nullable": true - }, - "budget_limit": { - "type": "number", - "example": 200 - }, - "alert_threshold": { - "type": "number", - "example": 80 - } - } - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/analytics/spending": { - "get": { - "summary": "Get spending trends for the authenticated user", - "description": "Returns current month spend, 6-month historical trend, and category breakdown derived from active subscriptions.", - "tags": [ - "Analytics" - ], - "security": [ - { - "bearerAuth": [] - }, - { - "cookieAuth": [] - } - ], - "responses": { - "200": { - "description": "Spending data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "current_month_spend": { - "type": "number", - "description": "Total normalized monthly spend across all active subscriptions", - "example": 89.97 - }, - "monthly_trend": { - "type": "array", - "description": "Spend per month for the last 6 months", - "items": { - "$ref": "#/components/schemas/MonthlySpend" - } - }, - "category_breakdown": { - "type": "array", - "description": "Spend grouped by subscription category", - "items": { - "$ref": "#/components/schemas/CategorySpend" - } - }, - "active_subscriptions": { - "type": "integer", - "description": "Total number of active subscriptions included in spend", - "example": 5 - } - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized — valid Bearer token or auth cookie required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/api/analytics/forecast": { - "get": { - "summary": "Get projected spending forecast for the next 6 months", - "description": "Projects monthly spend for the next 6 months based on currently active subscriptions. Accounts for subscription start dates and cancellations.", - "tags": [ - "Analytics" - ], - "security": [ - { - "bearerAuth": [] - }, - { - "cookieAuth": [] - } - ], - "responses": { - "200": { - "description": "Spending forecast", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "forecast": { - "type": "array", - "description": "Projected spend for each of the next 6 months", - "items": { - "$ref": "#/components/schemas/MonthlySpend" - } - }, - "avg_projected_monthly_spend": { - "type": "number", - "description": "Average projected monthly spend across the forecast period", - "example": 91.5 - } - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized — valid Bearer token or auth cookie required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - } - }, - "tags": [] -} \ No newline at end of file