diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index 4b9136a8..b8ff29f2 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -29,6 +29,8 @@ "rate-limit-redis": "^4.2.0", "redis": "^4.7.0", "sqlite3": "^5.1.7", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^9.0.1", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", @@ -43,6 +45,78 @@ "tsx": "4.23.1" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", @@ -1508,6 +1582,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -1855,6 +1935,12 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" "node_modules/@sentry/conventions": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", @@ -2156,6 +2242,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.0.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", @@ -2590,7 +2682,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true, "license": "MIT" }, "node_modules/base32.js": { @@ -2719,7 +2810,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2980,6 +3070,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3326,7 +3422,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, "license": "MIT" }, "node_modules/confbox": { @@ -3770,6 +3865,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4077,6 +4184,13 @@ "node": ">=4" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4509,7 +4623,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -5046,7 +5159,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6516,7 +6628,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7042,6 +7153,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7161,7 +7279,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8554,6 +8671,92 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.11", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.11.tgz", + "integrity": "sha512-NEZzRuxHHQkbG3GCjNbzz+XRDoM7AztnXyzc2VCW5RXUvZBDW7bb3W29/SPfvav3yOzqnDTOLP2Xzbjxo0bldQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -9170,6 +9373,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index 0034d4b2..d92dd71d 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -41,6 +41,8 @@ "rate-limit-redis": "^4.2.0", "redis": "^4.7.0", "sqlite3": "^5.1.7", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^9.0.1", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index d1837c53..ea401c78 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -20,6 +20,8 @@ const { poolGet, poolRun, poolAll } = require('./src/db'); const { logger } = require('./src/logger'); const xss = require('xss'); const { Keypair, StrKey } = require('@stellar/stellar-sdk'); +const swaggerUi = require('swagger-ui-express'); +const { swaggerSpec } = require('./src/swagger'); const { metricsMiddleware, getMetrics, @@ -214,6 +216,14 @@ app.use(rejectNestedObjects); // Enable HTTP response compression for responses exceeding 1KB (1024 bytes) app.use(compression({ threshold: 1024 })); +// --------------------------------------------------------------------------- +// Swagger UI — interactive API documentation available at /api-docs +// --------------------------------------------------------------------------- +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customSiteTitle: 'Stellar Tags API Docs', + swaggerOptions: { persistAuthorization: true }, +})); + scheduleCleanupJob(prisma); scheduleSoftDeletePurgeJob(prisma); const poolMonitor = schedulePoolMonitoring(prisma); @@ -307,6 +317,84 @@ const registerLocalUser = async ({ username, address }) => { ); }; +/** + * @swagger + * /federation: + * get: + * summary: Resolve a username tag or Stellar address via the federation protocol + * description: > + * Implements the Stellar federation protocol. Use `type=name` (default) to + * resolve a username tag (e.g. `alice*localhost`) to a Stellar address, or + * `type=id` to do the reverse lookup by Stellar public key. + * tags: + * - Federation + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * description: > + * The value to look up. For `type=name` provide a username tag + * (e.g. `alice*localhost`). For `type=id` provide a Stellar public key. + * example: alice*localhost + * - in: query + * name: type + * required: false + * schema: + * type: string + * enum: [name, id] + * default: name + * description: > + * Federation query type. `name` resolves a tag to an address; + * `id` resolves an address to a tag. + * responses: + * 200: + * description: Record found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FederationResponse' + * examples: + * nameResolution: + * summary: Resolve username tag → address + * value: + * stellar_address: alice*localhost + * account_id: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * withMemo: + * summary: Response with optional memo fields + * value: + * stellar_address: alice*localhost + * account_id: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * memo_type: text + * memo: payment-ref-001 + * 400: + * description: Missing `q` parameter + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: "Missing 'q' parameter" + * statusCode: 400 + * 404: + * description: Name tag or address not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: Name tag not found + * statusCode: 404 + * 500: + * description: Database lookup failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ // Expose /metrics endpoint for Prometheus to scrape app.get('/metrics', async (req, res) => { try { @@ -471,16 +559,105 @@ const verifyFreighterRegistrationSignature = ({ }; /** - * Registration endpoint with multi-signer threshold verification - * - * For single-signer accounts: - * - Signature must be the account's public key or a registered signer - * - Basic validation of address format - * - * For multi-signer accounts (enterprise): - * - Fetches account signers and thresholds from Horizon - * - Validates that provided signature(s) meet minimum threshold - * - Ensures authorization requirements are satisfied + * @swagger + * /register: + * post: + * summary: Register a new username–address mapping + * description: > + * Associates a human-readable username tag with a Stellar public key. + * Optionally accepts a SEP-0053 signature for multi-signer threshold + * verification. The endpoint is idempotent for the same + * (username, address) pair when an idempotency key header is supplied. + * tags: + * - Registration + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegisterRequest' + * examples: + * minimal: + * summary: Minimal registration (no signature) + * value: + * username: alice + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * withMemo: + * summary: Registration with a text memo + * value: + * username: alice + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * memo_type: text + * memo: payment-ref-001 + * withSignature: + * summary: Registration with Freighter signature (SEP-0053) + * value: + * username: alice + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * signature: BASE64_ENCODED_SIGNATURE + * signerAddress: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * responses: + * 201: + * description: Registration successful + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegisterResponse' + * example: + * ok: true + * username: alice*localhost + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * federation_address: alice*localhost + * 400: + * description: > + * Missing or invalid fields (username, address, memo validation, + * reserved username, or secret key detected). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: "Missing required fields: username and address are both required." + * statusCode: 400 + * 401: + * description: Signature verification failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Reserved username + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: This username is reserved and cannot be registered. + * statusCode: 403 + * 409: + * description: Address or username already registered + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: Address already registered + * statusCode: 409 + * 415: + * description: Content-Type must be application/json + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Database insertion or verification failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateSchema({ body: registerBodySchema }), async (req, res, next) => { // registerBodySchema has already guaranteed that username is a trimmed @@ -657,6 +834,110 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS app.all('/register', (req, res, next) => next(new ApiError('METHOD_NOT_ALLOWED'))); +/** + * @swagger + * /lookup: + * get: + * summary: Look up a username by Stellar address, or search users + * description: > + * Two modes of operation: + * 1. **Exact lookup** — supply `address` to retrieve the username registered + * to that Stellar public key. + * 2. **Paginated search** — supply `search` to find users whose username or + * address contains the given string (case-insensitive). + * + * Exactly one of `address` or `search` must be provided. + * tags: + * - Lookup + * parameters: + * - in: query + * name: address + * schema: + * type: string + * description: Stellar public key for exact reverse-lookup. + * example: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * - in: query + * name: search + * schema: + * type: string + * description: Partial username or address string for paginated search. + * example: alice + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * description: Page number (search mode only). + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * minimum: 1 + * maximum: 100 + * description: Results per page (search mode only). + * responses: + * 200: + * description: Record(s) found + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/LookupResponse' + * - $ref: '#/components/schemas/LookupPagedResponse' + * examples: + * exactLookup: + * summary: Exact address lookup + * value: + * username: alice*localhost + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * pagedSearch: + * summary: Paginated search results + * value: + * data: + * - username: alice*localhost + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * created_at: '2024-01-15T10:30:00.000Z' + * totalCount: 1 + * totalPages: 1 + * currentPage: 1 + * 400: + * description: Neither `address` nor `search` was provided + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: "Missing required parameter: provide 'address' for exact lookup or 'search' for paginated search" + * statusCode: 400 + * 404: + * description: No username registered for the given address + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * example: + * success: false + * error: Username not found for this address + * statusCode: 404 + * 500: + * description: Database lookup failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +app.get('/lookup', async (req, res, next) => { + const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + + if (!address && !search) { + const error = new Error("Missing required parameter: provide 'address' for exact lookup or 'search' for paginated search"); + error.statusCode = 400; + return next(error); + } app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res, next) => { const { address = '', search = '' } = req.query; @@ -742,6 +1023,64 @@ app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res } }); +/** + * @swagger + * /users: + * get: + * summary: List all registered users with optional search and pagination + * description: > + * Returns a paginated list of registered username–address pairs. + * Optionally filter by a partial username or address string. + * tags: + * - Lookup + * parameters: + * - in: query + * name: search + * schema: + * type: string + * description: Filter by partial username or address (case-insensitive). + * example: alice + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * description: Page number. + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * minimum: 1 + * maximum: 100 + * description: Results per page. + * responses: + * 200: + * description: Paginated list of users + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LookupPagedResponse' + * example: + * data: + * - username: alice*localhost + * address: GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ + * created_at: '2024-01-15T10:30:00.000Z' + * totalCount: 1 + * totalPages: 1 + * currentPage: 1 + * 500: + * description: Database error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +app.get('/users', async (req, res, next) => { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10)); + const search = typeof req.query.search === 'string' ? req.query.search : null; app.get('/users', validateSchema({ query: usersQuerySchema }), async (req, res, next) => { const { page, limit, search = null } = req.query; const skip = (page - 1) * limit; @@ -809,6 +1148,24 @@ app.get('/api/v1/time', (_req, res) => { res.status(200).json({ time: new Date().toISOString() }); }); +/** + * @swagger + * /health: + * get: + * summary: Server health check + * description: Returns status "ok" when the server is running. Useful for uptime monitoring and load-balancer health probes. + * tags: + * - Health + * responses: + * 200: + * description: Server is healthy + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/HealthResponse' + * example: + * status: ok + */ app.get('/health', async (_req, res) => { const checks = { database: null, redis: null }; let allOk = true; diff --git a/stellar-payment-platform/src/swagger.js b/stellar-payment-platform/src/swagger.js new file mode 100644 index 00000000..5c50d216 --- /dev/null +++ b/stellar-payment-platform/src/swagger.js @@ -0,0 +1,235 @@ +'use strict'; + +const swaggerJsdoc = require('swagger-jsdoc'); + +const options = { + definition: { + openapi: '3.0.0', + info: { + title: 'Stellar Tags API', + version: '1.0.0', + description: + 'REST API for the Stellar Tags payment platform. Provides username registration and federation lookups that map human-readable tags to Stellar addresses.', + contact: { + name: 'Stellar Tags', + url: 'https://stellar-tags.vercel.app', + }, + }, + servers: [ + { + url: 'http://localhost:{port}', + description: 'Local development server', + variables: { + port: { + default: '5000', + description: 'Port the server listens on (defaults to 5000)', + }, + }, + }, + { + url: 'https://stellar-tags-production.up.railway.app', + description: 'Production server', + }, + ], + tags: [ + { + name: 'Federation', + description: 'Stellar federation protocol — resolve username tags to Stellar addresses', + }, + { + name: 'Registration', + description: 'Register new username–address mappings', + }, + { + name: 'Lookup', + description: 'Reverse-lookup and search users by address or keyword', + }, + { + name: 'Health', + description: 'Server liveness check', + }, + ], + components: { + schemas: { + FederationResponse: { + type: 'object', + required: ['stellar_address', 'account_id'], + properties: { + stellar_address: { + type: 'string', + example: 'alice*stellar-tags-production.up.railway.app', + description: 'Fully-qualified Stellar federation address (username*domain)', + }, + account_id: { + type: 'string', + example: 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + description: 'Stellar public key (G…) associated with this tag', + }, + memo_type: { + type: 'string', + enum: ['text', 'id', 'hash'], + description: 'Type of Stellar memo attached to this account (omitted when absent)', + }, + memo: { + type: 'string', + description: 'Memo value matching memo_type (omitted when absent)', + }, + }, + }, + RegisterRequest: { + type: 'object', + required: ['username', 'address'], + properties: { + username: { + type: 'string', + minLength: 3, + example: 'alice', + description: + 'Desired username. Must be ≥ 3 characters. A domain suffix (*domain) is appended automatically if omitted.', + }, + address: { + type: 'string', + example: 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + description: 'Stellar public key (must start with G)', + }, + signature: { + type: 'string', + description: + 'Optional. Base64-encoded SEP-0053 signature or raw ed25519 public key for multi-signer verification.', + }, + signerAddress: { + type: 'string', + description: + 'Optional. Stellar public key of the signer when using Freighter signature flow.', + }, + memo_type: { + type: 'string', + enum: ['text', 'id', 'hash'], + description: 'Optional. Stellar memo type to associate with this registration.', + }, + memo: { + type: 'string', + description: 'Optional. Memo value (required when memo_type is provided).', + }, + }, + }, + RegisterResponse: { + type: 'object', + required: ['ok', 'username', 'address', 'federation_address'], + properties: { + ok: { + type: 'boolean', + example: true, + }, + username: { + type: 'string', + example: 'alice*localhost', + }, + address: { + type: 'string', + example: 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + }, + federation_address: { + type: 'string', + example: 'alice*localhost', + }, + memo_type: { + type: 'string', + description: 'Included only when memo was supplied at registration', + }, + memo: { + type: 'string', + description: 'Included only when memo was supplied at registration', + }, + verification: { + type: 'object', + description: 'Included only when a signature was provided', + properties: { + accountId: { type: 'string' }, + signerCount: { type: 'integer' }, + thresholdMet: { type: 'boolean' }, + requiredThreshold: { type: 'integer' }, + providedWeight: { type: 'integer' }, + }, + }, + }, + }, + LookupResponse: { + type: 'object', + required: ['username', 'address'], + properties: { + username: { + type: 'string', + example: 'alice*localhost', + }, + address: { + type: 'string', + example: 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + }, + }, + }, + LookupPagedResponse: { + type: 'object', + required: ['data', 'totalCount', 'totalPages', 'currentPage'], + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + username: { type: 'string', example: 'alice*localhost' }, + address: { + type: 'string', + example: 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + }, + created_at: { + type: 'string', + format: 'date-time', + example: '2024-01-15T10:30:00.000Z', + }, + }, + }, + }, + totalCount: { type: 'integer', example: 42 }, + totalPages: { type: 'integer', example: 5 }, + currentPage: { type: 'integer', example: 1 }, + }, + }, + HealthResponse: { + type: 'object', + required: ['status'], + properties: { + status: { + type: 'string', + example: 'ok', + }, + }, + }, + ErrorResponse: { + type: 'object', + required: ['success', 'error'], + properties: { + success: { + type: 'boolean', + example: false, + }, + error: { + type: 'string', + example: 'Missing required parameter', + }, + statusCode: { + type: 'integer', + example: 400, + }, + }, + }, + }, + }, + }, + // Scan server.js for JSDoc @swagger annotations + apis: ['./server.js'], +}; + +const swaggerSpec = swaggerJsdoc(options); + +module.exports = { swaggerSpec };