A tab that slept past the 15 minute access-token lifetime woke up holding a token every consumer then handed to the server independently. socket.io reconnected all seven namespaces with it, each gateway rejected the handshake and closed the socket, and because a server-initiated close reports `io server disconnect` the client never reconnects from it, so live updates stayed dead until the page was reloaded. The dashboard widgets fetch once on mount with no retry, so whatever failed in that window sat on "Failed to load" for the life of the page. Make a valid token the thing consumers ask for rather than something they hope is still good. Decode `exp` on the client and renew inside a 30 second skew before the token is used, so websocket handshakes, uploads and `api()` requests no longer discover the expiry one 401 at a time. Route every authenticated namespace through a shared factory that resolves the token per handshake and, on an auth rejection, refreshes and reconnects with bounded exponential backoff. Let the server say which rejections are worth retrying. Gateways now answer an authentication failure with an `unauthorized` event before closing, and stay silent on a permission denial, which a fresh token would not fix. Classifying that needed the gateways to stop throwing bare `Error` for both cases, so the handshake paths now throw `UnauthorizedException` or `ForbiddenException` as the project's conventions already require, and jsonwebtoken's own `TokenExpiredError` counts as an authentication failure since it is the single most common way a slept-through handshake fails. Collapse the twelve identical widget composables onto one loader that reloads a failed widget once a rejected request is followed by a successful refresh. Closes #942
BookOrbit - Server
NestJS 11 backend running on Fastify.
Running
From the repo root: pnpm dev (starts both server and client).
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
OpenAPI docs are disabled by default. Enable them with SWAGGER_ENABLED=true:
SWAGGER_ENABLED=true pnpm start:dev
Then open:
- Swagger UI: http://localhost:3000/api/docs
- OpenAPI JSON: http://localhost:3000/api/docs-json
Dev watch uses a Nest config without the Swagger metadata generator so the
server does not restart immediately after startup. Full build output still uses
the standard Nest config and generates src/metadata.ts for enriched docs.
Module structure
Each feature is a self-contained NestJS module under src/modules/:
src/modules/
├── auth/ Authentication (JWT, OIDC, password reset)
├── book/ Book CRUD, cover images, file serving
├── library/ Library management
├── scanner/ File system scanning, metadata extraction
├── collection/ User book collections
├── smart-scope/ Saved search/filter views
├── user/ User management
├── role/ Roles and permissions (RBAC)
├── metadata/ Book metadata providers
├── kobo/ Kobo device sync
├── annotation/ Highlights and annotations
├── bookmark/ Reading position bookmarks
├── reader-preferences/ Per-user reader settings
├── cbz/ CBZ archive handling
├── path/ File path utilities
├── seed/ Database seeding
└── app-settings/ Global application settings
Database
Schema files live in src/db/schema/, one per domain. Migrations are generated by Drizzle Kit.
pnpm db:generate add_some_column # generate migration from schema diff
pnpm db:migrate # apply pending migrations
pnpm db:studio # open Drizzle Studio (DB browser)
Testing
pnpm test # unit tests
pnpm test:watch # unit tests in watch mode
pnpm e2e:run # app-smoke end-to-end test from server package
pnpm test:cov # unit tests with coverage
For suite-based e2e runs, run from the repository root:
pnpm run e2e:list
pnpm run e2e:all
pnpm run e2e:run -- scanner-scenarios
Quality
pnpm type-check # TypeScript checks (no emit)
pnpm lint:check # Non-mutating lint checks
pnpm lint:fix # Auto-fix lint issues
pnpm format:check # Prettier check
pnpm format # Prettier write
Key conventions
- Constructor injection with
@Inject(DB)for the Drizzle instance. - Config via
@nestjs/confignamed configs (appConfig,dbConfig,authConfig,storageConfig,mailerConfig). Never readprocess.envdirectly in services. - DTOs use
class-validatordecorators. GlobalValidationPiperejects unknown fields. - Throw NestJS
HttpExceptionsubclasses (NotFoundException,ForbiddenException, etc.) - never rawError. - All user-owned data is scoped by
userId. Service methods check ownership.