diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 78fada8..2a6d592 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -57,6 +57,12 @@ jobs: - name: Type-check run: pnpm tsc --noEmit + - name: Check generated OpenAPI document + run: pnpm check:openapi + + - name: Check registered routes against OpenAPI + run: pnpm check:route-spec + - name: Test id: tests continue-on-error: true @@ -109,4 +115,4 @@ jobs: - name: Fail when tests fail if: steps.tests.outcome == 'failure' - run: exit 1 \ No newline at end of file + run: exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a4873e..3fca11e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -426,6 +426,9 @@ without inspecting the URL path. The canonical OpenAPI 3.0 spec lives at [`docs/openapi.yaml`](./docs/openapi.yaml). Update it whenever you add or modify an endpoint. The spec is the contract between the server and all consumers (frontend, mobile, third-party integrations). +Server CI also checks the methods and paths mounted in `server/src/routes/index.ts` +against `server/openapi.json`; update the route operations and regenerate the JSON +whenever a route is added, removed, or moved. --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fec1c38..78b0024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: ts-jest: specifier: 29.2.3 version: 29.2.3(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest@29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@20.14.10)(typescript@5.5.3)))(typescript@5.5.3) + ts-node: + specifier: 10.9.2 + version: 10.9.2(@swc/core@1.16.1)(@types/node@20.14.10)(typescript@5.5.3) ts-node-dev: specifier: 2.0.0 version: 2.0.0(@swc/core@1.16.1)(@types/node@20.14.10)(typescript@5.5.3) diff --git a/server/openapi.json b/server/openapi.json index adc8966..4ace0cb 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -381,6 +381,21 @@ } } }, + "DisputeBody": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Why the trade is being disputed.", + "example": "Seller did not deliver the airtime after payment was locked." + } + } + }, "PrepareBuyResponse": { "type": "object", "required": [ @@ -557,9 +572,19 @@ "type": "string", "example": "GABC1234..." }, + "kycStatus": { + "type": "string", + "enum": [ + "unverified", + "pending", + "verified", + "rejected" + ], + "example": "verified" + }, "referralCode": { "type": "string", - "example": "ABC12345" + "example": "K7QM2XWTHN4PZR8C" } } } @@ -1758,6 +1783,103 @@ } } }, + "/api/v1/trades/{id}/dispute": { + "post": { + "tags": [ + "Trades" + ], + "summary": "Dispute a locked trade", + "description": "Escalates a Locked trade to Disputed. Either the buyer or the seller of the trade may call this. An admin resolves the dispute via POST /api/v1/admin/trades/{id}/resolve.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisputeBody" + } + } + } + }, + "responses": { + "200": { + "description": "Trade successfully disputed.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TradeOffer" + } + } + } + } + } + }, + "400": { + "description": "Trade is not in a state that can be disputed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is neither the buyer nor the seller of this trade.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "Trade is already disputed or is no longer Locked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v1/wallet": { "get": { "tags": [ @@ -2078,6 +2200,24 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "summary": "PATCH /api/v1/profile", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + }, + "put": { + "summary": "PUT /api/v1/profile", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } } }, "/api/v1/profile/trades": { @@ -2235,13 +2375,42 @@ } } }, - "/api/events": { + "/api/v1/events": { "get": { "tags": [ "Events" ], "summary": "Server-Sent Events stream", - "description": "Persistent SSE connection for real-time trade status updates. Event types: `connected`, `trade_completed`, `trade_disputed`, `admin_alert`.", + "description": "Persistent SSE connection for real-time trade status updates. Event types: `connected`, `trade_completed`, `trade_disputed`, `admin_alert`. Also available, unversioned, at `/api/events` for backwards compatibility.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "SSE stream opened.", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/events": { + "get": { + "tags": [ + "Events" + ], + "summary": "Server-Sent Events stream (legacy alias)", + "description": "Unversioned alias for `/api/v1/events`, kept for backwards compatibility. Prefer `/api/v1/events` for new integrations.", "security": [ { "bearerAuth": [] @@ -2754,6 +2923,576 @@ } } } + }, + "/api/admin/analytics/assets": { + "get": { + "summary": "GET /api/admin/analytics/assets", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/analytics/overview": { + "get": { + "summary": "GET /api/admin/analytics/overview", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/analytics/trades/timeseries": { + "get": { + "summary": "GET /api/admin/analytics/trades/timeseries", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/audit-logs": { + "get": { + "summary": "GET /api/admin/audit-logs", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/flagged-accounts": { + "get": { + "summary": "GET /api/admin/flagged-accounts", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/fraud-flags": { + "get": { + "summary": "GET /api/admin/fraud-flags", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/metrics": { + "get": { + "summary": "GET /api/admin/metrics", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/queues": { + "get": { + "summary": "GET /api/admin/queues", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/trades": { + "get": { + "summary": "GET /api/admin/trades", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/users": { + "get": { + "summary": "GET /api/admin/users", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/users/{phone}": { + "get": { + "summary": "GET /api/admin/users/{phone}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/webhooks": { + "get": { + "summary": "GET /api/admin/webhooks", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/docs": { + "get": { + "summary": "GET /api/docs", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/docs.json": { + "get": { + "summary": "GET /api/docs.json", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/rates": { + "get": { + "summary": "GET /api/rates", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/test-async-error": { + "get": { + "summary": "GET /api/test-async-error", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades": { + "get": { + "summary": "GET /api/trades", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + }, + "post": { + "summary": "POST /api/trades", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}": { + "get": { + "summary": "GET /api/trades/{id}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}/buy": { + "post": { + "summary": "POST /api/trades/{id}/buy", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}/buy/prepare": { + "post": { + "summary": "POST /api/trades/{id}/buy/prepare", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}/confirm-delivery": { + "post": { + "summary": "POST /api/trades/{id}/confirm-delivery", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}/dispute": { + "post": { + "summary": "POST /api/trades/{id}/dispute", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/trades/{id}/rate": { + "post": { + "summary": "POST /api/trades/{id}/rate", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/users/me/export": { + "get": { + "summary": "GET /api/users/me/export", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/users/me/export/{exportId}": { + "get": { + "summary": "GET /api/users/me/export/{exportId}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/users/me/export/{exportId}/download": { + "get": { + "summary": "GET /api/users/me/export/{exportId}/download", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/users/{id}/ratings": { + "get": { + "summary": "GET /api/users/{id}/ratings", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/flagged-accounts": { + "get": { + "summary": "GET /api/v1/admin/flagged-accounts", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/fraud-flags": { + "get": { + "summary": "GET /api/v1/admin/fraud-flags", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/fraud-flags/{id}": { + "patch": { + "summary": "PATCH /api/v1/admin/fraud-flags/{id}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/metrics": { + "get": { + "summary": "GET /api/v1/admin/metrics", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/trades": { + "get": { + "summary": "GET /api/v1/admin/trades", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/trades/{id}/resolve": { + "post": { + "summary": "POST /api/v1/admin/trades/{id}/resolve", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "summary": "GET /api/v1/admin/users", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/users/{phone}": { + "get": { + "summary": "GET /api/v1/admin/users/{phone}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/webhooks": { + "get": { + "summary": "GET /api/v1/admin/webhooks", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/admin/webhooks/{id}/replay": { + "post": { + "summary": "POST /api/v1/admin/webhooks/{id}/replay", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/auth/revoke": { + "post": { + "summary": "POST /api/v1/auth/revoke", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/rates": { + "get": { + "summary": "GET /api/v1/rates", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/referrals": { + "get": { + "summary": "GET /api/v1/referrals", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/referrals/stats": { + "get": { + "summary": "GET /api/v1/referrals/stats", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/test-async-error": { + "get": { + "summary": "GET /api/v1/test-async-error", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/trades/{id}/rate": { + "post": { + "summary": "POST /api/v1/trades/{id}/rate", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/users/{id}/ratings": { + "get": { + "summary": "GET /api/v1/users/{id}/ratings", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/wallet/unlock": { + "post": { + "summary": "POST /api/v1/wallet/unlock", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/webhooks/paystack": { + "post": { + "summary": "POST /api/v1/webhooks/paystack", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/v1/webhooks/stellar": { + "post": { + "summary": "POST /api/v1/webhooks/stellar", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/kyc/submit": { + "post": { + "summary": "POST /api/kyc/submit", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/fraud-flags/{id}": { + "patch": { + "summary": "PATCH /api/admin/fraud-flags/{id}", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/trades/{id}/resolve": { + "post": { + "summary": "POST /api/admin/trades/{id}/resolve", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } + }, + "/api/admin/webhooks/{id}/replay": { + "post": { + "summary": "POST /api/admin/webhooks/{id}/replay", + "description": "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + "responses": { + "default": { + "description": "The response status and shape are defined by the route handler." + } + } + } } }, "tags": [ diff --git a/server/package.json b/server/package.json index 943f865..5f5aea9 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ "test:watch": "jest --watch", "generate:openapi": "ts-node scripts/generate-openapi.ts", "check:openapi": "ts-node scripts/generate-openapi.ts && git diff --exit-code openapi.json", + "check:route-spec": "ts-node scripts/check-openapi-routes.ts", "db:migrate": "ts-node src/db/migrate.ts", "db:migrate:rollback": "ts-node src/db/migrate.ts rollback" }, @@ -54,6 +55,7 @@ "jest": "29.7.0", "supertest": "7.0.0", "ts-jest": "29.2.3", + "ts-node": "10.9.2", "ts-node-dev": "2.0.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", diff --git a/server/scripts/check-openapi-routes.ts b/server/scripts/check-openapi-routes.ts new file mode 100644 index 0000000..0938aa2 --- /dev/null +++ b/server/scripts/check-openapi-routes.ts @@ -0,0 +1,194 @@ +/** + * Compare the statically registered Express route operations with the + * generated OpenAPI paths. Run with `pnpm check:route-spec` from server/. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const serverRoot = path.resolve(__dirname, ".."); +const routesDir = path.join(serverRoot, "src/routes"); +const httpMethods = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); +type Operation = `${string} ${string}`; + +function parseSource(filePath: string): ts.SourceFile { + return ts.createSourceFile( + filePath, + fs.readFileSync(filePath, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); +} + +function visit(node: ts.Node, callback: (node: ts.Node) => void): void { + callback(node); + ts.forEachChild(node, (child) => visit(child, callback)); +} + +function stringPaths(expression: ts.Expression | undefined, source: ts.SourceFile, line: number): string[] { + if (expression && (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression))) { + return [expression.text]; + } + if (expression && ts.isArrayLiteralExpression(expression)) { + return expression.elements.map((element) => { + if (ts.isStringLiteral(element) || ts.isNoSubstitutionTemplateLiteral(element)) return element.text; + throw new Error(`Non-static path in ${source.fileName}:${line}`); + }); + } + throw new Error(`Non-static path in ${source.fileName}:${line}`); +} + +function normalizePath(value: string): string { + let normalized = value.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); + if (!normalized.startsWith("/")) normalized = `/${normalized}`; + normalized = normalized.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); + if (normalized.length > 1) normalized = normalized.replace(/\/$/, ""); + return normalized; +} + +function mountedPath(prefix: string, route: string): string { + const joined = `${prefix.replace(/\/$/, "")}/${route.replace(/^\//, "")}`; + return normalizePath(joined || "/"); +} + +function routerNames(source: ts.SourceFile): Set { + const names = new Set(); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer || !ts.isCallExpression(declaration.initializer)) continue; + const factory = declaration.initializer.expression; + if ( + (ts.isIdentifier(factory) && factory.text === "Router") || + (ts.isPropertyAccessExpression(factory) && factory.name.text === "Router") + ) { + names.add(declaration.name.text); + } + } + } + return names; +} + +function registeredRoutes(source: ts.SourceFile, mount: string, routers: Set): Set { + const operations = new Set(); + + visit(source, (node) => { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return; + + const method = node.expression.name.text.toLowerCase(); + let routePath: ts.Expression | undefined; + const receiver = node.expression.expression; + + if (ts.isIdentifier(receiver) && routers.has(receiver.text)) { + if (method === "use" || method === "route" || method === "all") return; + if (!httpMethods.has(method)) return; + routePath = node.arguments[0]; + } else if (ts.isCallExpression(receiver) && ts.isPropertyAccessExpression(receiver.expression)) { + const routeFactory = receiver.expression; + if ( + routeFactory.name.text !== "route" || + !ts.isIdentifier(routeFactory.expression) || + !routers.has(routeFactory.expression.text) || + !httpMethods.has(method) + ) return; + routePath = receiver.arguments[0]; + } else { + return; + } + + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + for (const localPath of stringPaths(routePath, source, line)) { + // `all` is deliberately ignored above: in this repository it is used + // only as an authorization middleware gate before method-specific routes. + operations.add(`${method.toUpperCase()} ${mountedPath(mount, localPath)}`); + } + }); + + return operations; +} + +function collectRegisteredOperations(): Set { + const routeIndexPath = path.join(routesDir, "index.ts"); + const routeIndex = parseSource(routeIndexPath); + const importedRouters = new Map(); + for (const statement of routeIndex.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause?.name || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + const modulePath = path.resolve(routesDir, `${statement.moduleSpecifier.text}.ts`); + importedRouters.set(statement.importClause.name.text, modulePath); + } + + const operations = new Set(); + visit(routeIndex, (node) => { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.expression.getText(routeIndex) !== "app" || + node.expression.name.text !== "use" + ) return; + + const [prefix, router] = node.arguments; + if (!prefix || !ts.isStringLiteral(prefix) || !router || !ts.isIdentifier(router)) return; + const filePath = importedRouters.get(router.text); + if (!filePath) return; + + const routerSource = parseSource(filePath); + for (const operation of registeredRoutes(routerSource, prefix.text, routerNames(routerSource))) { + operations.add(operation); + } + }); + + // Liveness routes are registered directly on the Express app, outside + // routes/index.ts, but are part of the same public OpenAPI contract. + const appSource = parseSource(path.join(serverRoot, "src/index.ts")); + visit(appSource, (node) => { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.expression.getText(appSource) !== "app" || + !httpMethods.has(node.expression.name.text.toLowerCase()) + ) return; + + const line = appSource.getLineAndCharacterOfPosition(node.getStart(appSource)).line + 1; + for (const route of stringPaths(node.arguments[0], appSource, line)) { + // The wildcard OPTIONS handler terminates CORS preflights and is not an + // API operation that belongs in the OpenAPI contract. + if (node.expression.name.text.toLowerCase() === "options" && route === "*") continue; + operations.add(`${node.expression.name.text.toUpperCase()} ${normalizePath(route)}`); + } + }); + + return operations; +} + +function main(): void { + const specPath = path.join(serverRoot, "openapi.json"); + const spec = JSON.parse(fs.readFileSync(specPath, "utf8")) as { + paths?: Record>; + }; + const documented = new Set(); + for (const [route, definition] of Object.entries(spec.paths ?? {})) { + for (const method of Object.keys(definition)) { + if (httpMethods.has(method.toLowerCase())) { + documented.add(`${method.toUpperCase()} ${normalizePath(route)}`); + } + } + } + + const registered = collectRegisteredOperations(); + const missing = [...registered].filter((operation) => !documented.has(operation)).sort(); + const stale = [...documented].filter((operation) => !registered.has(operation)).sort(); + + if (missing.length || stale.length) { + const sections = [ + "OpenAPI route coverage mismatch:", + ...(missing.length ? ["Missing from server/openapi.json:", ...missing.map((entry) => ` + ${entry}`)] : []), + ...(stale.length ? ["Not registered in server/src/routes/index.ts or server/src/index.ts:", ...stale.map((entry) => ` - ${entry}`)] : []), + ]; + throw new Error(sections.join("\n")); + } + + console.log(`OpenAPI route coverage passed (${registered.size} registered operations).`); +} + +main(); diff --git a/server/src/openapi.ts b/server/src/openapi.ts index 354cd86..61705f8 100644 --- a/server/src/openapi.ts +++ b/server/src/openapi.ts @@ -2047,3 +2047,80 @@ export const openApiDocument = { { name: "Admin", description: "Platform administration and monitoring" }, ], } as const; + +// Keep every registered Express method/path represented in the generated +// document. These coverage entries provide stable operation placeholders for +// legacy aliases and handlers that do not yet have route-specific schemas. +// The route-coverage check fails when a new handler is added without updating +// this map (or the detailed operations above). +const routeCoverageOperations: Record = { + "/api/admin/analytics/assets": ["get"], + "/api/admin/analytics/overview": ["get"], + "/api/admin/analytics/trades/timeseries": ["get"], + "/api/admin/audit-logs": ["get"], + "/api/admin/flagged-accounts": ["get"], + "/api/admin/fraud-flags": ["get"], + "/api/admin/metrics": ["get"], + "/api/admin/queues": ["get"], + "/api/admin/trades": ["get"], + "/api/admin/users": ["get"], + "/api/admin/users/{phone}": ["get"], + "/api/admin/webhooks": ["get"], + "/api/docs": ["get"], + "/api/docs.json": ["get"], + "/api/rates": ["get"], + "/api/test-async-error": ["get"], + "/api/trades": ["get", "post"], + "/api/trades/{id}": ["get"], + "/api/trades/{id}/buy": ["post"], + "/api/trades/{id}/buy/prepare": ["post"], + "/api/trades/{id}/confirm-delivery": ["post"], + "/api/trades/{id}/dispute": ["post"], + "/api/trades/{id}/rate": ["post"], + "/api/users/me/export": ["get"], + "/api/users/me/export/{exportId}": ["get"], + "/api/users/me/export/{exportId}/download": ["get"], + "/api/users/{id}/ratings": ["get"], + "/api/v1/admin/flagged-accounts": ["get"], + "/api/v1/admin/fraud-flags": ["get"], + "/api/v1/admin/fraud-flags/{id}": ["patch"], + "/api/v1/admin/metrics": ["get"], + "/api/v1/admin/trades": ["get"], + "/api/v1/admin/trades/{id}/resolve": ["post"], + "/api/v1/admin/users": ["get"], + "/api/v1/admin/users/{phone}": ["get"], + "/api/v1/admin/webhooks": ["get"], + "/api/v1/admin/webhooks/{id}/replay": ["post"], + "/api/v1/auth/revoke": ["post"], + "/api/v1/events": ["get"], + "/api/v1/profile": ["patch", "put"], + "/api/v1/rates": ["get"], + "/api/v1/referrals": ["get"], + "/api/v1/referrals/stats": ["get"], + "/api/v1/test-async-error": ["get"], + "/api/v1/trades/{id}/dispute": ["post"], + "/api/v1/trades/{id}/rate": ["post"], + "/api/v1/users/{id}/ratings": ["get"], + "/api/v1/wallet/unlock": ["post"], + "/api/v1/webhooks/paystack": ["post"], + "/api/v1/webhooks/stellar": ["post"], + "/api/kyc/submit": ["post"], + "/api/admin/fraud-flags/{id}": ["patch"], + "/api/admin/trades/{id}/resolve": ["post"], + "/api/admin/webhooks/{id}/replay": ["post"], +}; + +const mutablePaths = openApiDocument.paths as unknown as Record>; +for (const [route, methods] of Object.entries(routeCoverageOperations)) { + const pathItem = mutablePaths[route] ?? {}; + for (const method of methods) { + pathItem[method] ??= { + summary: `${method.toUpperCase()} ${route}`, + description: "Registered Express route. Add route-specific request and response schemas when expanding this API documentation.", + responses: { + default: { description: "The response status and shape are defined by the route handler." }, + }, + }; + } + mutablePaths[route] = pathItem; +}