Add Swagger docs at /docs and /openapi.json with split OpenAPI spec

Made-with: Cursor
This commit is contained in:
Michaël
2026-02-28 21:14:27 -03:00
parent bdb4892014
commit 0a6d86c8e8
21 changed files with 679 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
UserProfile:
type: object
properties:
lightning_address:
type: string
nullable: true
name:
type: string
nullable: true

View File

@@ -0,0 +1,9 @@
NIP98:
type: http
scheme: bearer
bearerFormat: Nostr
description: NIP-98 HTTP Auth (Authorization Nostr <base64(json)>)
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT

View File

@@ -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'

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"

View File

@@ -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(

View File

@@ -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<object> {
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;