feat(server): add swagger setup for public api documentation

Introduces Swagger and OpenAPI documentation generation for the
backend API. Configures bearer authentication globally while
allowing explicit bypasses for public endpoints. Exports nested
DTO classes across several modules so the NestJS Swagger plugin
can correctly reflect their schema definitions.

Closes #399
This commit is contained in:
neon
2026-06-29 01:52:30 +00:00
parent 246389b050
commit 2065af41f9
31 changed files with 352 additions and 257 deletions
+1
View File
@@ -29,6 +29,7 @@ local/
# Build artifacts
coverage/
*.tsbuildinfo
server/src/metadata.ts
# Test results (generated artifacts - uploaded to CI, not committed)
test-results/
+17 -1
View File
@@ -183,6 +183,21 @@ This starts the server, client, and types watcher concurrently with hot reload.
If PostgreSQL is not running, start it first with `pnpm run db:up`.
### API Docs
Generated OpenAPI docs are disabled by default. To enable them for local development, set `SWAGGER_ENABLED=true` in `server/.env` or prefix the dev command:
```bash
SWAGGER_ENABLED=true pnpm dev
```
When enabled, open:
| Docs | URL |
| ------------ | ----------------------------------- |
| Swagger UI | http://localhost:3000/api/docs |
| OpenAPI JSON | http://localhost:3000/api/docs-json |
<details>
<summary>Running workspaces separately</summary>
@@ -210,13 +225,14 @@ pnpm --filter @bookorbit/types dev
These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments.
| Variable | Purpose | Default (dev) |
| --------------- | ---------------------------------- | --------------------------------------------------------- |
| ----------------- | ---------------------------------- | --------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
| `PORT` | Server listen port | `3000` |
| `NODE_ENV` | Runtime mode | `development` |
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
---
+89 -198
View File
@@ -276,6 +276,9 @@ importers:
"@nestjs/schedule":
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)
"@nestjs/swagger":
specifier: ^11.4.4
version: 11.4.4(@fastify/static@9.1.3)(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
"@nestjs/terminus":
specifier: ^11.1.1
version: 11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -3865,6 +3868,12 @@ packages:
}
engines: { node: ">=8" }
"@microsoft/tsdoc@0.16.0":
resolution:
{
integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==,
}
"@napi-rs/nice-android-arm-eabi@1.1.1":
resolution:
{
@@ -4111,6 +4120,22 @@ packages:
peerDependencies:
"@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
"@nestjs/mapped-types@2.1.1":
resolution:
{
integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==,
}
peerDependencies:
"@nestjs/common": ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
"@nestjs/passport@11.0.5":
resolution:
{
@@ -4167,6 +4192,26 @@ packages:
prettier:
optional: true
"@nestjs/swagger@11.4.4":
resolution:
{
integrity: sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==,
}
peerDependencies:
"@fastify/static": ^8.0.0 || ^9.0.0
"@nestjs/common": ^11.0.1
"@nestjs/core": ^11.0.1
class-transformer: "*"
class-validator: "*"
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
"@fastify/static":
optional: true
class-transformer:
optional: true
class-validator:
optional: true
"@nestjs/terminus@11.1.1":
resolution:
{
@@ -4571,136 +4616,6 @@ packages:
integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==,
}
"@parcel/watcher-android-arm64@2.5.6":
resolution:
{
integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm64]
os: [android]
"@parcel/watcher-darwin-arm64@2.5.6":
resolution:
{
integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm64]
os: [darwin]
"@parcel/watcher-darwin-x64@2.5.6":
resolution:
{
integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==,
}
engines: { node: ">= 10.0.0" }
cpu: [x64]
os: [darwin]
"@parcel/watcher-freebsd-x64@2.5.6":
resolution:
{
integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==,
}
engines: { node: ">= 10.0.0" }
cpu: [x64]
os: [freebsd]
"@parcel/watcher-linux-arm-glibc@2.5.6":
resolution:
{
integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm]
os: [linux]
libc: [glibc]
"@parcel/watcher-linux-arm-musl@2.5.6":
resolution:
{
integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm]
os: [linux]
libc: [musl]
"@parcel/watcher-linux-arm64-glibc@2.5.6":
resolution:
{
integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm64]
os: [linux]
libc: [glibc]
"@parcel/watcher-linux-arm64-musl@2.5.6":
resolution:
{
integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm64]
os: [linux]
libc: [musl]
"@parcel/watcher-linux-x64-glibc@2.5.6":
resolution:
{
integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==,
}
engines: { node: ">= 10.0.0" }
cpu: [x64]
os: [linux]
libc: [glibc]
"@parcel/watcher-linux-x64-musl@2.5.6":
resolution:
{
integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==,
}
engines: { node: ">= 10.0.0" }
cpu: [x64]
os: [linux]
libc: [musl]
"@parcel/watcher-win32-arm64@2.5.6":
resolution:
{
integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==,
}
engines: { node: ">= 10.0.0" }
cpu: [arm64]
os: [win32]
"@parcel/watcher-win32-ia32@2.5.6":
resolution:
{
integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==,
}
engines: { node: ">= 10.0.0" }
cpu: [ia32]
os: [win32]
"@parcel/watcher-win32-x64@2.5.6":
resolution:
{
integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==,
}
engines: { node: ">= 10.0.0" }
cpu: [x64]
os: [win32]
"@parcel/watcher@2.5.6":
resolution:
{
integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==,
}
engines: { node: ">= 10.0.0" }
"@pdf-lib/standard-fonts@1.0.0":
resolution:
{
@@ -5189,6 +5104,12 @@ packages:
cpu: [x64]
os: [win32]
"@scarf/scarf@1.4.0":
resolution:
{
integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==,
}
"@sec-ant/readable-stream@0.4.1":
resolution:
{
@@ -11095,12 +11016,6 @@ packages:
integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==,
}
node-addon-api@7.1.1:
resolution:
{
integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==,
}
node-emoji@1.11.0:
resolution:
{
@@ -13192,6 +13107,12 @@ packages:
}
engines: { node: ">=18" }
swagger-ui-dist@5.32.6:
resolution:
{
integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==,
}
symbol-observable@4.0.0:
resolution:
{
@@ -16966,6 +16887,8 @@ snapshots:
"@lukeed/ms@2.0.2": {}
"@microsoft/tsdoc@0.16.0": {}
"@napi-rs/nice-android-arm-eabi@1.1.1":
optional: true
@@ -17126,6 +17049,14 @@ snapshots:
"@types/jsonwebtoken": 9.0.10
jsonwebtoken: 9.0.3
"@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)":
dependencies:
"@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
"@nestjs/passport@11.0.5(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)":
dependencies:
"@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -17192,6 +17123,22 @@ snapshots:
transitivePeerDependencies:
- chokidar
"@nestjs/swagger@11.4.4(@fastify/static@9.1.3)(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)":
dependencies:
"@microsoft/tsdoc": 0.16.0
"@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
"@nestjs/core": 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
"@nestjs/mapped-types": 2.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash: 4.18.1
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6
optionalDependencies:
"@fastify/static": 9.1.3
class-transformer: 0.5.1
class-validator: 0.15.1
"@nestjs/terminus@11.1.1(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)":
dependencies:
"@nestjs/common": 11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -17367,66 +17314,6 @@ snapshots:
dependencies:
"@noble/hashes": 1.8.0
"@parcel/watcher-android-arm64@2.5.6":
optional: true
"@parcel/watcher-darwin-arm64@2.5.6":
optional: true
"@parcel/watcher-darwin-x64@2.5.6":
optional: true
"@parcel/watcher-freebsd-x64@2.5.6":
optional: true
"@parcel/watcher-linux-arm-glibc@2.5.6":
optional: true
"@parcel/watcher-linux-arm-musl@2.5.6":
optional: true
"@parcel/watcher-linux-arm64-glibc@2.5.6":
optional: true
"@parcel/watcher-linux-arm64-musl@2.5.6":
optional: true
"@parcel/watcher-linux-x64-glibc@2.5.6":
optional: true
"@parcel/watcher-linux-x64-musl@2.5.6":
optional: true
"@parcel/watcher-win32-arm64@2.5.6":
optional: true
"@parcel/watcher-win32-ia32@2.5.6":
optional: true
"@parcel/watcher-win32-x64@2.5.6":
optional: true
"@parcel/watcher@2.5.6":
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.4
optionalDependencies:
"@parcel/watcher-android-arm64": 2.5.6
"@parcel/watcher-darwin-arm64": 2.5.6
"@parcel/watcher-darwin-x64": 2.5.6
"@parcel/watcher-freebsd-x64": 2.5.6
"@parcel/watcher-linux-arm-glibc": 2.5.6
"@parcel/watcher-linux-arm-musl": 2.5.6
"@parcel/watcher-linux-arm64-glibc": 2.5.6
"@parcel/watcher-linux-arm64-musl": 2.5.6
"@parcel/watcher-linux-x64-glibc": 2.5.6
"@parcel/watcher-linux-x64-musl": 2.5.6
"@parcel/watcher-win32-arm64": 2.5.6
"@parcel/watcher-win32-ia32": 2.5.6
"@parcel/watcher-win32-x64": 2.5.6
"@pdf-lib/standard-fonts@1.0.0":
dependencies:
pako: 1.0.11
@@ -17629,6 +17516,8 @@ snapshots:
"@rollup/rollup-win32-x64-msvc@4.62.2":
optional: true
"@scarf/scarf@1.4.0": {}
"@sec-ant/readable-stream@0.4.1": {}
"@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.5(typescript@6.0.3))":
@@ -21225,8 +21114,6 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@7.1.1: {}
node-emoji@1.11.0:
dependencies:
lodash: 4.18.1
@@ -22592,6 +22479,10 @@ snapshots:
transitivePeerDependencies:
- "@typescript-eslint/types"
swagger-ui-dist@5.32.6:
dependencies:
"@scarf/scarf": 1.4.0
symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}
+4
View File
@@ -5,6 +5,10 @@ DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
PORT=3000
NODE_ENV=development
# API docs (optional)
# Set to true to serve Swagger UI at /api/docs and OpenAPI JSON at /api/docs-json.
# SWAGGER_ENABLED=false
# Auth
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=15m
+1
View File
@@ -0,0 +1 @@
src/metadata.ts
+13
View File
@@ -10,6 +10,19 @@ Server only: `pnpm start:dev` (from this directory).
The server loads environment variables from `.env` in this directory. See `.env.example` for all available options.
## API docs
Generated OpenAPI docs are disabled by default. Enable them with `SWAGGER_ENABLED=true`:
```bash
SWAGGER_ENABLED=true pnpm start:dev
```
Then open:
- Swagger UI: http://localhost:3000/api/docs
- OpenAPI JSON: http://localhost:3000/api/docs-json
## Module structure
Each feature is a self-contained NestJS module under `src/modules/`:
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'],
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**', 'src/metadata.ts'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
+11 -1
View File
@@ -4,6 +4,16 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc"
"builder": "swc",
"typeCheck": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"dtoFileNameSuffix": [".dto.ts"]
}
}
]
}
}
+1
View File
@@ -64,6 +64,7 @@
"@nestjs/platform-fastify": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.4",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.27",
@@ -1,4 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ApiExtension } from '@nestjs/swagger';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const Public = () => applyDecorators(SetMetadata(IS_PUBLIC_KEY, true), ApiExtension('x-public', true));
+8
View File
@@ -10,7 +10,9 @@ function resetEnv(): void {
delete process.env.APP_URL;
delete process.env.APP_VERSION;
delete process.env.OIDC_ALLOW_LOCAL_ISSUERS;
delete process.env.SWAGGER_ENABLED;
delete process.env.KOBO_CLOUDSCRAPER_PYTHON;
delete process.env.KOREADER_PLUGIN_PATH;
delete process.env.DATABASE_URL;
delete process.env.JWT_SECRET;
delete process.env.JWT_EXPIRES_IN;
@@ -46,7 +48,9 @@ describe('config', () => {
githubReleasesRepo: 'bookorbit/bookorbit',
githubReleasesToken: undefined,
oidcAllowLocalIssuers: false,
swaggerEnabled: false,
koboCloudscraperPython: undefined,
koreaderPluginSourcePath: undefined,
});
});
@@ -55,7 +59,9 @@ describe('config', () => {
process.env.APP_URL = 'https://bookorbit.local';
process.env.APP_VERSION = 'v2.3.4';
process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true';
process.env.SWAGGER_ENABLED = 'true';
process.env.KOBO_CLOUDSCRAPER_PYTHON = '/opt/bookorbit-python/bin/python';
process.env.KOREADER_PLUGIN_PATH = '/opt/koreader/bookorbit.koplugin';
process.env.GITHUB_RELEASES_REPO = 'acme/app';
process.env.GITHUB_RELEASES_TOKEN = 'ghp_example';
@@ -66,7 +72,9 @@ describe('config', () => {
githubReleasesRepo: 'acme/app',
githubReleasesToken: 'ghp_example',
oidcAllowLocalIssuers: true,
swaggerEnabled: true,
koboCloudscraperPython: '/opt/bookorbit-python/bin/python',
koreaderPluginSourcePath: '/opt/koreader/bookorbit.koplugin',
});
});
+1
View File
@@ -8,6 +8,7 @@ export const appConfig = registerAs('app', () => ({
githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit',
githubReleasesToken: process.env.GITHUB_RELEASES_TOKEN?.trim() || undefined,
oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false),
swaggerEnabled: parseBooleanFlag(process.env.SWAGGER_ENABLED, false),
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
koreaderPluginSourcePath: process.env.KOREADER_PLUGIN_PATH?.trim() || undefined,
}));
+20
View File
@@ -79,6 +79,26 @@ describe('validateEnv', () => {
).toThrow('OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off');
});
it('accepts boolean-like values for SWAGGER_ENABLED', () => {
for (const SWAGGER_ENABLED of ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']) {
expect(() =>
validateEnv({
...BASE_ENV,
SWAGGER_ENABLED,
}),
).not.toThrow();
}
});
it('rejects invalid SWAGGER_ENABLED values', () => {
expect(() =>
validateEnv({
...BASE_ENV,
SWAGGER_ENABLED: 'maybe',
}),
).toThrow('SWAGGER_ENABLED must be one of true/false/1/0/yes/no/on/off');
});
it('accepts a custom Book Dock container path', () => {
expect(() =>
validateEnv({
+16 -16
View File
@@ -1,6 +1,8 @@
import { parseIntoClientConfig } from 'pg-connection-string';
import { z } from 'zod';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
function isValidPostgresConnectionString(value: string): boolean {
if (!value.trim()) {
return false;
@@ -18,6 +20,17 @@ function isValidPostgresConnectionString(value: string): boolean {
}
}
function booleanEnvFlag(name: string) {
return z
.string()
.trim()
.toLowerCase()
.refine((val) => BOOLEAN_ENV_VALUES.includes(val), {
message: `${name} must be one of ${BOOLEAN_ENV_VALUES.join('/')}`,
})
.optional();
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(),
@@ -43,22 +56,9 @@ const envSchema = z.object({
TRUST_PROXY: z.string().optional(),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
OIDC_ALLOW_LOCAL_ISSUERS: z
.string()
.trim()
.toLowerCase()
.refine((val) => ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(val), {
message: 'OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off',
})
.optional(),
CSP_ALLOW_CLOUDFLARE_INSIGHTS: z
.string()
.trim()
.toLowerCase()
.refine((val) => ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(val), {
message: 'CSP_ALLOW_CLOUDFLARE_INSIGHTS must be one of true/false/1/0/yes/no/on/off',
})
.optional(),
OIDC_ALLOW_LOCAL_ISSUERS: booleanEnvFlag('OIDC_ALLOW_LOCAL_ISSUERS'),
CSP_ALLOW_CLOUDFLARE_INSIGHTS: booleanEnvFlag('CSP_ALLOW_CLOUDFLARE_INSIGHTS'),
SWAGGER_ENABLED: booleanEnvFlag('SWAGGER_ENABLED'),
KOBO_CLOUDSCRAPER_PYTHON: z
.string()
.transform((val) => val.trim())
+8
View File
@@ -3,6 +3,7 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
import { ValidationPipe } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Logger } from 'nestjs-pino';
import type { ConfigType } from '@nestjs/config';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { join } from 'path';
@@ -11,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import fastifyHelmet from '@fastify/helmet';
import fastifyCompress from '@fastify/compress';
import { appConfig } from './config/config';
import { setupSwaggerDocs } from './swagger';
import {
parseBooleanEnv,
parseTrustProxy,
@@ -67,6 +70,11 @@ async function bootstrap() {
app.useGlobalFilters(new GlobalExceptionFilter());
const appConfiguration = app.get<ConfigType<typeof appConfig>>(appConfig.KEY);
if (appConfiguration.swaggerEnabled) {
await setupSwaggerDocs(app, appConfiguration);
}
await app.register(fastifyHelmet as never, {
crossOriginOpenerPolicy: false,
contentSecurityPolicy: {
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, Matches, MaxLength, MinLength, ValidateNested } from 'class-validator';
class ClaimMappingDto {
export class ClaimMappingDto {
@IsString()
@MaxLength(256)
username: string;
@@ -19,7 +19,7 @@ class ClaimMappingDto {
groups: string;
}
class AutoProvisionDto {
export class AutoProvisionDto {
@IsBoolean()
enabled: boolean;
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator';
class ClaimMappingDto {
export class ClaimMappingDto {
@IsString()
@MaxLength(256)
username: string;
@@ -19,7 +19,7 @@ class ClaimMappingDto {
groups: string;
}
class AutoProvisionDto {
export class AutoProvisionDto {
@IsBoolean()
enabled: boolean;
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateNested } from 'class-validator';
class ClaimMappingDto {
export class ClaimMappingDto {
@IsString()
@MaxLength(256)
username: string;
@@ -19,7 +19,7 @@ class ClaimMappingDto {
groups: string;
}
class AutoProvisionDto {
export class AutoProvisionDto {
@IsBoolean()
enabled: boolean;
@@ -10,7 +10,7 @@ import type {
import { ALL_METADATA_FIELDS } from '@bookorbit/types';
class ScoreConditionDto {
export class ScoreConditionDto {
@IsDefined()
@IsBoolean()
enabled: boolean;
@@ -22,7 +22,7 @@ class ScoreConditionDto {
threshold: number;
}
class MissingFieldsConditionDto {
export class MissingFieldsConditionDto {
@IsDefined()
@IsBoolean()
enabled: boolean;
@@ -33,13 +33,13 @@ class MissingFieldsConditionDto {
fields: MetadataField[];
}
class NeverFetchedConditionDto {
export class NeverFetchedConditionDto {
@IsDefined()
@IsBoolean()
enabled: boolean;
}
class ConditionsDto implements BookMetadataFetchConditions {
export class ConditionsDto implements BookMetadataFetchConditions {
@IsDefined()
@IsObject()
@ValidateNested()
@@ -59,7 +59,7 @@ class ConditionsDto implements BookMetadataFetchConditions {
neverFetched: NeverFetchedConditionDto;
}
class ScoreConditionOverrideDto implements Partial<ScoreConditionDto> {
export class ScoreConditionOverrideDto implements Partial<ScoreConditionDto> {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@@ -71,7 +71,7 @@ class ScoreConditionOverrideDto implements Partial<ScoreConditionDto> {
threshold?: number;
}
class MissingFieldsConditionOverrideDto implements Partial<MissingFieldsConditionDto> {
export class MissingFieldsConditionOverrideDto implements Partial<MissingFieldsConditionDto> {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@@ -82,13 +82,13 @@ class MissingFieldsConditionOverrideDto implements Partial<MissingFieldsConditio
fields?: MetadataField[];
}
class NeverFetchedConditionOverrideDto implements Partial<NeverFetchedConditionDto> {
export class NeverFetchedConditionOverrideDto implements Partial<NeverFetchedConditionDto> {
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
class ConditionsOverrideDto implements BookMetadataFetchConditionsOverride {
export class ConditionsOverrideDto implements BookMetadataFetchConditionsOverride {
@IsOptional()
@IsObject()
@ValidateNested()
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
class CollectionOrderItem {
export class CollectionOrderItem {
@IsInt()
@Min(1)
id: number;
@@ -3,13 +3,13 @@ import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { SaveProgressDto } from './koreader-progress.dto';
import { KoreaderSaveProgressDto } from './koreader-progress.dto';
const BASE = { document: 'abc123', percentage: 0.42 };
describe('SaveProgressDto', () => {
describe('KoreaderSaveProgressDto', () => {
it('accepts an xpointer string progress from reflowable documents', async () => {
const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: '/body/DocFragment[8]/body/p[12]/text().0' });
const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: '/body/DocFragment[8]/body/p[12]/text().0' });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
@@ -17,7 +17,7 @@ describe('SaveProgressDto', () => {
});
it('coerces the numeric page progress sent for paged documents into a string', async () => {
const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: 42 });
const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: 42 });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
@@ -25,7 +25,7 @@ describe('SaveProgressDto', () => {
});
it('keeps progress undefined when omitted', async () => {
const dto = plainToInstance(SaveProgressDto, { ...BASE });
const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
@@ -33,7 +33,7 @@ describe('SaveProgressDto', () => {
});
it('rejects progress values that are neither string nor number', async () => {
const dto = plainToInstance(SaveProgressDto, { ...BASE, progress: true });
const dto = plainToInstance(KoreaderSaveProgressDto, { ...BASE, progress: true });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
@@ -1,7 +1,7 @@
import { Transform } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class SaveProgressDto {
export class KoreaderSaveProgressDto {
@IsString()
document!: string;
@@ -24,7 +24,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { KoreaderAuthGuard } from './koreader-auth.guard';
import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderService } from './koreader.service';
import { CreateKoreaderUserDto, DownloadPluginPackageDto, SaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto';
import { CreateKoreaderUserDto, DownloadPluginPackageDto, KoreaderSaveProgressDto, TestConnectionDto, UpdateKoreaderUserDto } from './dto';
@Controller('koreader')
export class KoreaderController {
@@ -51,7 +51,7 @@ export class KoreaderController {
@Public()
@UseGuards(KoreaderAuthGuard)
@Put('syncs/progress')
async saveProgress(@CurrentUser() user: RequestUser, @Body() dto: SaveProgressDto) {
async saveProgress(@CurrentUser() user: RequestUser, @Body() dto: KoreaderSaveProgressDto) {
return this.koreaderService.saveProgress(user.id, dto);
}
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, Min, ValidateNested } from 'class-validator';
class LibraryOrderItem {
export class LibraryOrderItem {
@IsInt()
@Min(1)
id: number;
@@ -30,7 +30,7 @@ export class FieldPreferenceDto {
mergeStrategy!: MergeStrategy;
}
class GenreOptionsDto {
export class GenreOptionsDto {
@IsIn(GENRE_MERGE_MODES)
mode!: GenreMergeMode;
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength, Min, ValidateNested } from 'class-validator';
class UserMappingDto {
export class UserMappingDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
@@ -13,7 +13,7 @@ class UserMappingDto {
targetUserId!: number;
}
class PathMappingDto {
export class PathMappingDto {
@IsString()
@IsNotEmpty()
@MaxLength(1024)
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from 'class-validator';
class PathMappingInputDto {
export class PathMappingInputDto {
@IsString()
@IsNotEmpty()
@MaxLength(1024)
@@ -1,7 +1,7 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, ArrayUnique, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
class SmartScopeOrderItem {
export class SmartScopeOrderItem {
@IsInt()
@Min(1)
id: number;
+32
View File
@@ -0,0 +1,32 @@
import type { OpenAPIObject } from '@nestjs/swagger';
import { normalizeSwaggerDocument } from './swagger';
describe('normalizeSwaggerDocument', () => {
it('uses operation-level security so public routes do not inherit bearer auth', () => {
const document = {
openapi: '3.0.0',
info: { title: 'BookOrbit API', version: 'test' },
paths: {
'/api/v1/auth/login': {
post: {
'x-public': true,
responses: { '200': { description: '' } },
},
},
'/api/v1/books': {
get: {
responses: { '200': { description: '' } },
},
},
},
security: [{ bearer: [] }],
} as unknown as OpenAPIObject;
const result = normalizeSwaggerDocument(document);
expect(result.security).toBeUndefined();
expect(result.paths['/api/v1/auth/login']?.post?.security).toEqual([]);
expect(result.paths['/api/v1/books']?.get?.security).toEqual([{ bearer: [] }]);
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { ConfigType } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { appConfig } from './config/config';
const SWAGGER_METADATA_MODULE_PATH = './metadata.js';
const PUBLIC_OPERATION_EXTENSION = 'x-public';
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'search'] as const;
type AppConfiguration = ConfigType<typeof appConfig>;
type SwaggerPluginMetadataFactory = () => Promise<Record<string, any>>;
type SwaggerOperation = {
security?: Array<Record<string, string[]>>;
[PUBLIC_OPERATION_EXTENSION]?: boolean;
};
type SwaggerPathItem = OpenAPIObject['paths'][string] & Record<string, SwaggerOperation | unknown>;
export async function setupSwaggerDocs(app: NestFastifyApplication, config: AppConfiguration): Promise<void> {
await loadSwaggerPluginMetadata();
const openApiConfig = new DocumentBuilder()
.setTitle('BookOrbit API')
.setDescription('BookOrbit server API')
.setVersion(config.version)
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'bearer')
.build();
const documentFactory = () => normalizeSwaggerDocument(SwaggerModule.createDocument(app, openApiConfig));
SwaggerModule.setup('api/docs', app, documentFactory, {
customSiteTitle: 'BookOrbit API Docs',
jsonDocumentUrl: 'api/docs-json',
raw: ['json'],
swaggerOptions: {
persistAuthorization: true,
},
});
}
export function normalizeSwaggerDocument(document: OpenAPIObject): OpenAPIObject {
delete document.security;
for (const pathItem of Object.values(document.paths)) {
applyOperationSecurity(pathItem as SwaggerPathItem);
}
return document;
}
function applyOperationSecurity(pathItem: SwaggerPathItem): void {
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!isSwaggerOperation(operation)) {
continue;
}
operation.security = operation[PUBLIC_OPERATION_EXTENSION] === true ? [] : [{ bearer: [] }];
}
}
function isSwaggerOperation(value: unknown): value is SwaggerOperation {
return typeof value === 'object' && value !== null;
}
async function loadSwaggerPluginMetadata(): Promise<void> {
try {
const metadataModule = (await import(SWAGGER_METADATA_MODULE_PATH)) as { default?: SwaggerPluginMetadataFactory };
if (typeof metadataModule.default === 'function') {
await SwaggerModule.loadPluginMetadata(metadataModule.default);
}
} catch (err) {
if (isMissingSwaggerMetadata(err)) {
return;
}
throw err;
}
}
function isMissingSwaggerMetadata(err: unknown): boolean {
const code = typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: unknown }).code) : '';
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') {
return false;
}
const message = err instanceof Error ? err.message : '';
return message.includes('/metadata') || message.includes('\\metadata') || message.includes(SWAGGER_METADATA_MODULE_PATH);
}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*.test.ts"]
"exclude": ["node_modules", "test", "dist", "src/metadata.ts", "**/*.test.ts"]
}