diff --git a/backend/openapi/components/schemas/claim.yaml b/backend/openapi/components/schemas/claim.yaml new file mode 100644 index 0000000..87a7ac7 --- /dev/null +++ b/backend/openapi/components/schemas/claim.yaml @@ -0,0 +1,24 @@ +QuoteResult: + type: object + properties: + quote_id: + type: string + payout_sats: + type: integer + expires_at: + type: integer +ConfirmResult: + type: object + properties: + success: + type: boolean + already_consumed: + type: boolean + payout_sats: + type: integer + next_eligible_at: + type: integer + message: + type: string + payment_hash: + type: string diff --git a/backend/openapi/components/schemas/common.yaml b/backend/openapi/components/schemas/common.yaml new file mode 100644 index 0000000..cb24e85 --- /dev/null +++ b/backend/openapi/components/schemas/common.yaml @@ -0,0 +1,12 @@ +ApiError: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + details: + type: string + next_eligible_at: + type: integer diff --git a/backend/openapi/components/schemas/faucet.yaml b/backend/openapi/components/schemas/faucet.yaml new file mode 100644 index 0000000..5fd63c8 --- /dev/null +++ b/backend/openapi/components/schemas/faucet.yaml @@ -0,0 +1,64 @@ +FaucetConfig: + type: object + properties: + faucetEnabled: + type: boolean + emergencyStop: + type: boolean + cooldownDays: + type: integer + minAccountAgeDays: + type: integer + minActivityScore: + type: integer + faucetMinSats: + type: integer + faucetMaxSats: + type: integer +Stats: + type: object + properties: + balanceSats: + type: integer + totalPaidSats: + type: integer + totalClaims: + type: integer + claimsLast24h: + type: integer + dailyBudgetSats: + type: integer + spentTodaySats: + type: integer + recentPayouts: + type: array + items: + type: object + properties: + pubkey_prefix: + type: string + payout_sats: + type: integer + claimed_at: + type: integer + recentDeposits: + type: array + items: + type: object + properties: + amount_sats: + type: integer + source: + type: string + enum: [lightning, cashu] + created_at: + type: integer +DepositInfo: + type: object + properties: + lightningAddress: + type: string + nullable: true + lnurlp: + type: string + nullable: true diff --git a/backend/openapi/components/schemas/user.yaml b/backend/openapi/components/schemas/user.yaml new file mode 100644 index 0000000..3744e63 --- /dev/null +++ b/backend/openapi/components/schemas/user.yaml @@ -0,0 +1,9 @@ +UserProfile: + type: object + properties: + lightning_address: + type: string + nullable: true + name: + type: string + nullable: true diff --git a/backend/openapi/components/security.yaml b/backend/openapi/components/security.yaml new file mode 100644 index 0000000..c8d48d0 --- /dev/null +++ b/backend/openapi/components/security.yaml @@ -0,0 +1,9 @@ +NIP98: + type: http + scheme: bearer + bearerFormat: Nostr + description: NIP-98 HTTP Auth (Authorization Nostr ) +BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/backend/openapi/index.yaml b/backend/openapi/index.yaml new file mode 100644 index 0000000..20546ec --- /dev/null +++ b/backend/openapi/index.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + title: LNFaucet API + description: | + Lightning Network faucet: request small amounts of sats for testing. + Auth via NIP-98 (Nostr) or npub-only (limited). + version: 1.0.0 +servers: + - url: http://localhost:3001 + description: Local development +tags: + - name: Public + description: No authentication required + - name: Auth + description: NIP-98 or npub login + - name: Claim + description: Request sats (rate limited) + - name: User + description: Profile and refresh +paths: + /health: + $ref: './path-items/health.yaml' + /config: + $ref: './path-items/config.yaml' + /stats: + $ref: './path-items/stats.yaml' + /deposit: + $ref: './path-items/deposit.yaml' + /deposit/redeem-cashu: + $ref: './path-items/deposit-redeem-cashu.yaml' + /auth/login: + $ref: './path-items/auth-login.yaml' + /auth/login-npub: + $ref: './path-items/auth-login-npub.yaml' + /auth/me: + $ref: './path-items/auth-me.yaml' + /claim/quote: + $ref: './path-items/claim-quote.yaml' + /claim/confirm: + $ref: './path-items/claim-confirm.yaml' + /user/refresh-profile: + $ref: './path-items/user-refresh-profile.yaml' +components: + securitySchemes: + $ref: './components/security.yaml' + schemas: + FaucetConfig: + $ref: './components/schemas/faucet.yaml#/FaucetConfig' + Stats: + $ref: './components/schemas/faucet.yaml#/Stats' + DepositInfo: + $ref: './components/schemas/faucet.yaml#/DepositInfo' + QuoteResult: + $ref: './components/schemas/claim.yaml#/QuoteResult' + ConfirmResult: + $ref: './components/schemas/claim.yaml#/ConfirmResult' + UserProfile: + $ref: './components/schemas/user.yaml#/UserProfile' + ApiError: + $ref: './components/schemas/common.yaml#/ApiError' diff --git a/backend/openapi/path-items/auth-login-npub.yaml b/backend/openapi/path-items/auth-login-npub.yaml new file mode 100644 index 0000000..b296065 --- /dev/null +++ b/backend/openapi/path-items/auth-login-npub.yaml @@ -0,0 +1,33 @@ +post: + tags: [Auth] + summary: Sign in with npub only (limited) + requestBody: + content: + application/json: + schema: + type: object + required: [npub] + properties: + npub: + type: string + responses: + "200": + description: Token and pubkey + content: + application/json: + schema: + type: object + properties: + token: + type: string + pubkey: + type: string + method: + type: string + example: npub + "400": + description: Invalid npub + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/auth-login.yaml b/backend/openapi/path-items/auth-login.yaml new file mode 100644 index 0000000..20cfd3f --- /dev/null +++ b/backend/openapi/path-items/auth-login.yaml @@ -0,0 +1,20 @@ +post: + tags: [Auth] + summary: Sign in with NIP-98 (returns JWT) + security: + - NIP98: [] + responses: + "200": + description: Token and pubkey + content: + application/json: + schema: + type: object + properties: + token: + type: string + pubkey: + type: string + method: + type: string + example: nip98 diff --git a/backend/openapi/path-items/auth-me.yaml b/backend/openapi/path-items/auth-me.yaml new file mode 100644 index 0000000..95ab47c --- /dev/null +++ b/backend/openapi/path-items/auth-me.yaml @@ -0,0 +1,23 @@ +get: + tags: [Auth] + summary: Current user from Bearer token + security: + - BearerAuth: [] + responses: + "200": + description: Pubkey and method + content: + application/json: + schema: + type: object + properties: + pubkey: + type: string + method: + type: string + "401": + description: Unauthorized or invalid token + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/claim-confirm.yaml b/backend/openapi/path-items/claim-confirm.yaml new file mode 100644 index 0000000..e709562 --- /dev/null +++ b/backend/openapi/path-items/claim-confirm.yaml @@ -0,0 +1,39 @@ +post: + tags: [Claim] + summary: Confirm quote and pay (rate limited) + security: + - BearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + required: [quote_id] + properties: + quote_id: + type: string + responses: + "200": + description: Success or already consumed + content: + application/json: + schema: + $ref: "../components/schemas/claim.yaml#/ConfirmResult" + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" + "404": + description: Quote not found or expired + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" + "502": + description: Lightning payment failed + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/claim-quote.yaml b/backend/openapi/path-items/claim-quote.yaml new file mode 100644 index 0000000..d3cab55 --- /dev/null +++ b/backend/openapi/path-items/claim-quote.yaml @@ -0,0 +1,28 @@ +post: + tags: [Claim] + summary: Create claim quote (rate limited) + security: + - BearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + required: [lightning_address] + properties: + lightning_address: + type: string + description: user@domain.tld + responses: + "200": + description: Quote + content: + application/json: + schema: + $ref: "../components/schemas/claim.yaml#/QuoteResult" + "403": + description: Not eligible or budget exceeded + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/config.yaml b/backend/openapi/path-items/config.yaml new file mode 100644 index 0000000..1110baa --- /dev/null +++ b/backend/openapi/path-items/config.yaml @@ -0,0 +1,10 @@ +get: + tags: [Public] + summary: Public faucet config + responses: + "200": + description: Faucet configuration + content: + application/json: + schema: + $ref: "../components/schemas/faucet.yaml#/FaucetConfig" diff --git a/backend/openapi/path-items/deposit-redeem-cashu.yaml b/backend/openapi/path-items/deposit-redeem-cashu.yaml new file mode 100644 index 0000000..b80853c --- /dev/null +++ b/backend/openapi/path-items/deposit-redeem-cashu.yaml @@ -0,0 +1,48 @@ +post: + tags: [Public] + summary: Redeem Cashu token + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + description: Cashu token (cashuA... or cashuB...) + responses: + "200": + description: Redeem result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + paid: + type: boolean + amount: + type: integer + invoiceAmount: + type: integer + netAmount: + type: integer + to: + type: string + message: + type: string + "400": + description: Invalid token or address + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" + "502": + description: Redeem failed + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/deposit.yaml b/backend/openapi/path-items/deposit.yaml new file mode 100644 index 0000000..383034e --- /dev/null +++ b/backend/openapi/path-items/deposit.yaml @@ -0,0 +1,10 @@ +get: + tags: [Public] + summary: Deposit info + responses: + "200": + description: Lightning address and LNURLp + content: + application/json: + schema: + $ref: "../components/schemas/faucet.yaml#/DepositInfo" diff --git a/backend/openapi/path-items/health.yaml b/backend/openapi/path-items/health.yaml new file mode 100644 index 0000000..fa98f6f --- /dev/null +++ b/backend/openapi/path-items/health.yaml @@ -0,0 +1,14 @@ +get: + tags: [Public] + summary: Health check + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok diff --git a/backend/openapi/path-items/stats.yaml b/backend/openapi/path-items/stats.yaml new file mode 100644 index 0000000..76b6064 --- /dev/null +++ b/backend/openapi/path-items/stats.yaml @@ -0,0 +1,16 @@ +get: + tags: [Public] + summary: Faucet stats + responses: + "200": + description: Stats (balance, paid, claims) + content: + application/json: + schema: + $ref: "../components/schemas/faucet.yaml#/Stats" + "500": + description: Internal error + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/openapi/path-items/user-refresh-profile.yaml b/backend/openapi/path-items/user-refresh-profile.yaml new file mode 100644 index 0000000..a965434 --- /dev/null +++ b/backend/openapi/path-items/user-refresh-profile.yaml @@ -0,0 +1,18 @@ +post: + tags: [User] + summary: Refresh Nostr profile (kind 0) + security: + - BearerAuth: [] + responses: + "200": + description: lightning_address and name + content: + application/json: + schema: + $ref: "../components/schemas/user.yaml#/UserProfile" + "500": + description: Profile fetch failed + content: + application/json: + schema: + $ref: "../components/schemas/common.yaml#/ApiError" diff --git a/backend/package-lock.json b/backend/package-lock.json index b72e3b8..d231179 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "lnfaucet-backend", "version": "1.0.0", "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "better-sqlite3": "^11.6.0", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -15,6 +16,7 @@ "express-rate-limit": "^7.4.1", "nostr-tools": "^2.4.4", "pg": "^8.13.1", + "swagger-ui-express": "^5.0.1", "uuid": "^10.0.0" }, "devDependencies": { @@ -23,11 +25,60 @@ "@types/express": "^4.17.21", "@types/node": "^22.9.0", "@types/pg": "^8.11.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", "tsx": "^4.19.2", "typescript": "^5.6.3" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "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": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -509,6 +560,13 @@ "url": "https://paulmillr.com/funding/" } }, + "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/@scure/base": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", @@ -619,6 +677,12 @@ "dev": true, "license": "MIT" }, + "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/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -695,6 +759,17 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -715,6 +790,42 @@ "node": ">= 0.6" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -858,6 +969,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/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1185,6 +1302,28 @@ "express": ">= 4.11" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1422,6 +1561,24 @@ "node": ">= 0.10" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1622,6 +1779,13 @@ "wrappy": "1" } }, + "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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1883,6 +2047,15 @@ "node": ">= 6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2135,6 +2308,30 @@ "node": ">=0.10.0" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "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-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", diff --git a/backend/package.json b/backend/package.json index 8722af2..cade6a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "migrate": "tsx src/db/migrate.ts" }, "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "better-sqlite3": "^11.6.0", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -17,6 +18,7 @@ "express-rate-limit": "^7.4.1", "nostr-tools": "^2.4.4", "pg": "^8.13.1", + "swagger-ui-express": "^5.0.1", "uuid": "^10.0.0" }, "devDependencies": { @@ -25,6 +27,7 @@ "@types/express": "^4.17.21", "@types/node": "^22.9.0", "@types/pg": "^8.11.10", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", "tsx": "^4.19.2", "typescript": "^5.6.3" diff --git a/backend/src/index.ts b/backend/src/index.ts index d2f6e27..27f365c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import publicRoutes from "./routes/public.js"; import authRoutes from "./routes/auth.js"; import claimRoutes from "./routes/claim.js"; import userRoutes from "./routes/user.js"; +import docsRoutes from "./routes/docs.js"; const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -39,6 +40,7 @@ async function main() { }) ); + app.use("/", docsRoutes); app.use("/", publicRoutes); app.use("/auth", authRoutes); app.use( diff --git a/backend/src/routes/docs.ts b/backend/src/routes/docs.ts new file mode 100644 index 0000000..0becc20 --- /dev/null +++ b/backend/src/routes/docs.ts @@ -0,0 +1,40 @@ +import { Router, Request, Response } from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import SwaggerParser from "@apidevtools/swagger-parser"; +import swaggerUi from "swagger-ui-express"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const openApiPath = path.join(__dirname, "..", "..", "openapi", "index.yaml"); + +let cachedSpec: object | null = null; + +async function getBundledSpec(): Promise { + if (cachedSpec) return cachedSpec; + cachedSpec = (await SwaggerParser.bundle(openApiPath)) as object; + return cachedSpec; +} + +const router = Router(); + +router.get("/openapi.json", async (_req: Request, res: Response) => { + try { + const spec = await getBundledSpec(); + res.json(spec); + } catch (err) { + console.error("[docs] Failed to bundle OpenAPI spec:", err); + res.status(500).json({ code: "openapi_error", message: "Failed to load API spec" }); + } +}); + +router.use( + "/docs", + swaggerUi.serve, + swaggerUi.setup(null, { + swaggerOptions: { + url: "/openapi.json", + }, + }) +); + +export default router;