build: move local development off port 3000

Run the dev API on 6262 and Vite on 6263 with strictPort; production
defaults stay on 3000. Define the dev client origin once and read the
typed appConfig instead of repeating its fallback in every gateway.
This commit is contained in:
neon
2026-09-23 18:18:51 +00:00
parent 1dd29c742f
commit 4b86ff73cf
35 changed files with 132 additions and 113 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ env:
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
BOOKS_PATH: ../local/data
APP_URL: http://localhost:5173
APP_URL: http://localhost:6263
jobs:
run-suite:
+2 -2
View File
@@ -6,9 +6,9 @@ Vue 3 SPA built with Vite and Tailwind CSS v4.
From the repo root: `pnpm dev` (starts both server and client).
Client only: `pnpm dev` (from this directory). Runs at http://localhost:5173.
Client only: `pnpm dev` (from this directory). Runs at http://localhost:6263.
The Vite dev server proxies `/api` and `/socket.io` requests to the backend at `localhost:3000`.
The Vite dev server proxies `/api` and `/socket.io` requests to the backend at `localhost:6262`.
## IDE setup
+3 -1
View File
@@ -9,7 +9,7 @@ import { VitePWA } from 'vite-plugin-pwa'
const apiAgent = new Agent({ keepAlive: true })
/** Lets a second dev client point at a throwaway API instance, so restart testing leaves the main stack alone. */
const apiTarget = process.env.BOOKORBIT_API_TARGET ?? 'http://localhost:3000'
const apiTarget = process.env.BOOKORBIT_API_TARGET ?? 'http://localhost:6262'
// https://vite.dev/config/
export default defineConfig({
@@ -141,6 +141,8 @@ export default defineConfig({
exclude: ['@embedpdf/core', '@embedpdf/core/vue'],
},
server: {
port: 6263,
strictPort: true,
host: true,
allowedHosts: true,
proxy: {
+10 -10
View File
@@ -12,8 +12,8 @@ BookOrbit is a pnpm monorepo with three workspaces: a Vue 3 single-page app, a N
```mermaid
graph LR
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:5173</small>"]
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:3000/api</small>"]
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:6263</small>"]
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:6262/api</small>"]
DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
WS["WebSocket<br/><small>Socket.IO</small>"]
Types["Shared Types<br/><small>@bookorbit/types</small>"]
@@ -162,8 +162,8 @@ If everything worked, you will see the server, client, and types watcher start u
| Check | Expected |
| ---------------------------------------- | ------------------------------------------ |
| Open http://localhost:5173 | BookOrbit login page loads |
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` |
| Open http://localhost:6263 | BookOrbit login page loads |
| Open http://localhost:6262/api/v1/health | Returns `{"status":"ok"}` |
| `docker ps` | Shows `bookorbit-dev-db` container running |
---
@@ -178,8 +178,8 @@ This starts the server, client, and types watcher concurrently with hot reload.
| Service | URL |
| ------- | ------------------------- |
| Client | http://localhost:5173 |
| API | http://localhost:3000/api |
| Client | http://localhost:6263 |
| API | http://localhost:6262/api |
If PostgreSQL is not running, start it first with `pnpm run db:up`.
@@ -195,8 +195,8 @@ When enabled, open:
| Docs | URL |
| ------------ | ----------------------------------- |
| Swagger UI | http://localhost:3000/api/docs |
| OpenAPI JSON | http://localhost:3000/api/docs-json |
| Swagger UI | http://localhost:6262/api/docs |
| OpenAPI JSON | http://localhost:6262/api/docs-json |
<details>
<summary>Running workspaces separately</summary>
@@ -234,7 +234,7 @@ These are the variables you are most likely to need during development. See `ser
| `PODCAST_ENCRYPTION_KEY` | Podcast URL encryption key; required in production | Falls back to `JWT_SECRET` in development |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
| `APP_URL` | Base URL for email links | `http://localhost:6263` |
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
@@ -471,7 +471,7 @@ pnpm run e2e:run -- <suite-id>
**Port already in use**
The server defaults to port 3000 (configured via `PORT` in `server/.env`) and the client to 5173 (Vite default). If either is occupied, find the process that owns it and stop it, or change the server port in `server/.env`.
Local development runs the server on port 6262 (`PORT` in `server/.env`) and the client on 6263 (`server.port` in `client/vite.config.ts`, with `strictPort`, so Vite fails instead of silently moving). Both avoid 3000, which many other self-hosted apps and dev tools claim. A server started without `PORT`, such as the Docker image, still listens on 3000. If either is occupied, find the process that owns it and stop it, or change the server port in `server/.env`.
**Hot reload not picking up changes**
+2 -2
View File
@@ -2,7 +2,7 @@
DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
# App
PORT=3000
PORT=6262
# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0.
# Use 127.0.0.1 for local-only native deployments.
# HOST=0.0.0.0
@@ -92,7 +92,7 @@ APP_DATA_PATH=../local/data
# DISABLE_LOCAL_AUTH=false
# App URL (used for building password reset links in emails)
APP_URL=http://localhost:5173
APP_URL=http://localhost:6263
# Email encryption key (optional but recommended - 64-char hex string for AES-256)
# Generate with: openssl rand -hex 32
+2 -2
View File
@@ -20,8 +20,8 @@ SWAGGER_ENABLED=true pnpm start:dev
Then open:
- Swagger UI: http://localhost:3000/api/docs
- OpenAPI JSON: http://localhost:3000/api/docs-json
- Swagger UI: http://localhost:6262/api/docs
- OpenAPI JSON: http://localhost:6262/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
@@ -12,7 +12,7 @@ afterEach(() => {
describe('wsCorsOrigin', () => {
it('falls back to the dev client when nothing is configured', () => {
delete process.env.CLIENT_URL;
expect(wsCorsOrigin()).toBe('http://localhost:5173');
expect(wsCorsOrigin()).toBe('http://localhost:6263');
});
it('drops a trailing slash, which no Origin header ever carries', () => {
@@ -32,6 +32,6 @@ describe('wsCorsOrigin', () => {
it('falls back rather than throwing on a malformed value', () => {
process.env.CLIENT_URL = 'not a url';
expect(wsCorsOrigin()).toBe('http://localhost:5173');
expect(wsCorsOrigin()).toBe('http://localhost:6263');
});
});
+1 -2
View File
@@ -1,5 +1,4 @@
/** What a dev client is served from, and the only origin allowed when nothing was configured. */
const DEV_CLIENT_ORIGIN = 'http://localhost:5173';
import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
/**
* The origin a socket gateway's CORS check allows.
+1 -1
View File
@@ -64,7 +64,7 @@ describe('config', () => {
expect(appConfig()).toEqual({
nodeEnv: 'development',
host: '0.0.0.0',
appUrl: 'http://localhost:5173',
appUrl: 'http://localhost:6263',
nativeRedirectUri: 'bookorbit://oauth2-callback',
version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit',
+2 -1
View File
@@ -1,10 +1,11 @@
import { registerAs } from '@nestjs/config';
import { join, resolve } from 'path';
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST?.trim() || '0.0.0.0',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
appUrl: process.env.APP_URL ?? DEV_CLIENT_ORIGIN,
// Private-use scheme the iOS app registers with ASWebAuthenticationSession. Compiled into the
// client binary, so it is the same for every provider on every deployment; configurable only so
// a rebranded fork can change it.
+5
View File
@@ -0,0 +1,5 @@
/**
* Where `pnpm dev` serves the web client (`server.port` in `client/vite.config.ts`), and the origin
* every client-facing URL falls back to when `APP_URL` or `CLIENT_URL` is unset.
*/
export const DEV_CLIENT_ORIGIN = 'http://localhost:6263';
+3 -1
View File
@@ -5,6 +5,8 @@ import { z } from 'zod';
import { APP_FEATURES } from '@bookorbit/types';
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
@@ -81,7 +83,7 @@ const envSchema = z.object({
AUDIOLESS_EPUB_MAX_METADATA_BYTES: z.coerce.number().int().positive().optional(),
AUDIOLESS_EPUB_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().optional(),
CLIENT_URL: z.string().url().optional(),
APP_URL: z.string().url().default('http://localhost:5173'),
APP_URL: z.string().url().default(DEV_CLIENT_ORIGIN),
TRUST_PROXY: trustProxyEnv(),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
+2 -1
View File
@@ -14,6 +14,7 @@ import fastifyHelmet from '@fastify/helmet';
import fastifyCompress from '@fastify/compress';
import type { FastifyInstance } from 'fastify';
import { appConfig } from './config/config';
import { DEV_CLIENT_ORIGIN } from './config/dev-client-origin';
import { setupSwaggerDocs } from './swagger';
import {
parseBooleanEnv,
@@ -93,7 +94,7 @@ async function bootstrap() {
if (process.env.NODE_ENV !== 'production') {
app.enableCors({
origin: process.env.CLIENT_URL ?? 'http://localhost:5173',
origin: process.env.CLIENT_URL ?? DEV_CLIENT_ORIGIN,
credentials: true,
});
}
@@ -2,7 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { OidcService } from './oidc.service';
const APP_URL = 'http://localhost:5173';
const APP_URL = 'http://localhost:6263';
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
const NATIVE_REDIRECT_URI = 'bookorbit://oauth2-callback';
@@ -82,13 +82,7 @@ function makeService() {
const auditEvents = {
emit: vi.fn(),
};
const configService = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'app.appUrl') return APP_URL;
if (key === 'app.nativeRedirectUri') return NATIVE_REDIRECT_URI;
return undefined;
}),
};
const appConfiguration = { appUrl: APP_URL, nativeRedirectUri: NATIVE_REDIRECT_URI };
const authenticationPolicy = {
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
};
@@ -106,7 +100,7 @@ function makeService() {
userService as never,
authService as never,
auditEvents as never,
configService as never,
appConfiguration as never,
authenticationPolicy as never,
);
+6 -5
View File
@@ -1,6 +1,6 @@
import { OidcCallbackDto } from '../dto/oidc-callback.dto';
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { compare } from 'bcryptjs';
import type { FastifyReply } from 'fastify';
import { AuditAction, AuditResource, AuthenticationMethod, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types';
@@ -20,6 +20,7 @@ import { OidcStateService } from './oidc-state.service';
import { OidcTokenClientService } from './oidc-token-client.service';
import { OidcTokenValidatorService } from './oidc-token-validator.service';
import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service';
import { appConfig } from '../../../config/config';
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
@@ -53,11 +54,11 @@ export class OidcService {
private readonly userService: UserService,
private readonly authService: AuthService,
private readonly auditEvents: AuditEventsService,
private readonly configService: ConfigService,
@Inject(appConfig.KEY) appConfiguration: ConfigType<typeof appConfig>,
private readonly authenticationPolicy: AuthenticationPolicyService,
) {
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
this.nativeRedirectUri = this.configService.get<string>('app.nativeRedirectUri') ?? 'bookorbit://oauth2-callback';
this.appUrl = appConfiguration.appUrl.replace(/\/$/, '');
this.nativeRedirectUri = appConfiguration.nativeRedirectUri;
}
async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
@@ -1,22 +1,22 @@
import { isAllowedRedirectUri } from './redirect-uri';
const POLICY = {
appUrl: 'http://localhost:5173',
appUrl: 'http://localhost:6263',
nativeRedirectUri: 'bookorbit://oauth2-callback',
};
describe('isAllowedRedirectUri', () => {
describe('web redirect URI', () => {
it('accepts the exact web callback', () => {
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback', POLICY)).toBe(true);
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback', POLICY)).toBe(true);
});
it('accepts a web callback carrying a query string', () => {
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback?redirect=/library', POLICY)).toBe(true);
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback?redirect=/library', POLICY)).toBe(true);
});
it('tolerates a trailing slash on the configured app URL', () => {
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback', { ...POLICY, appUrl: 'http://localhost:5173/' })).toBe(true);
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback', { ...POLICY, appUrl: 'http://localhost:6263/' })).toBe(true);
});
it('rejects a different origin', () => {
@@ -24,7 +24,7 @@ describe('isAllowedRedirectUri', () => {
});
it('rejects a different path on the right origin', () => {
expect(isAllowedRedirectUri('http://localhost:5173/somewhere-else', POLICY)).toBe(false);
expect(isAllowedRedirectUri('http://localhost:6263/somewhere-else', POLICY)).toBe(false);
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
*/
export interface RedirectUriPolicy {
/** Web client origin, e.g. `http://localhost:5173`. Trailing slash already stripped. */
/** Web client origin, e.g. `https://books.example.com`. Trailing slash already stripped. */
appUrl: string;
/** Private-use scheme the native clients hand to the IdP, e.g. `bookorbit://oauth2-callback`. */
nativeRedirectUri: string;
@@ -9,7 +9,7 @@ function makeGateway() {
const queueRepo = { getStatusSummary: vi.fn() };
const enrichmentConfig = { isPaused: vi.fn() };
const session = { getSnapshot: vi.fn() };
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const appConfiguration = { appUrl: 'http://localhost:6263' };
const gateway = new AuthorEnrichmentGateway(
jwtService as any,
@@ -17,7 +17,7 @@ function makeGateway() {
queueRepo as any,
enrichmentConfig as any,
session as any,
configService as any,
appConfiguration as any,
);
return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session };
@@ -90,7 +90,7 @@ describe('AuthorEnrichmentGateway', () => {
expect(server.engine.opts.cors).toEqual({
methods: ['GET'],
origin: 'http://localhost:5173',
origin: 'http://localhost:6263',
credentials: true,
});
});
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Permission, type AuthenticationMethod, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
@@ -12,6 +12,7 @@ import { AuthorEnrichmentRepository } from './author-enrichment.repository';
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
export const AUTHOR_ENRICHMENT_STATUS_EVENT = 'author-enrichment:status';
@@ -27,9 +28,9 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
private readonly queueRepo: AuthorEnrichmentRepository,
private readonly enrichmentConfig: AuthorEnrichmentConfigService,
private readonly session: AuthorEnrichmentSessionService,
config: ConfigService,
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
this.clientOrigin = app.appUrl;
}
afterInit(server: Server): void {
@@ -96,7 +96,7 @@ describe('KoboSyncController', () => {
const req = {
headers: {
'x-forwarded-host': '192.168.8.134',
'x-forwarded-port': '5173',
'x-forwarded-port': '6263',
'x-forwarded-proto': 'http',
host: '192.168.8.134',
},
@@ -108,9 +108,9 @@ describe('KoboSyncController', () => {
const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never);
const resources = payload.Resources as Record<string, string>;
expect(resources.image_host).toBe('http://192.168.8.134:5173');
expect(resources.library_sync).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/sync');
expect(resources.reading_state).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/{Ids}/state');
expect(resources.image_host).toBe('http://192.168.8.134:6263');
expect(resources.library_sync).toBe('http://192.168.8.134:6263/api/v1/kobo/dev/v1/library/sync');
expect(resources.reading_state).toBe('http://192.168.8.134:6263/api/v1/kobo/dev/v1/library/{Ids}/state');
});
it('initialization does not append x-forwarded-port when it is the default for the scheme', () => {
@@ -31,12 +31,15 @@ import { KoboSyncService } from './services/kobo-sync.service';
import { KoboReadingStateService } from './services/kobo-reading-state.service';
import type { KoboProxyResponse } from './services/kobo-proxy.service';
import { KoboProxyService } from './services/kobo-proxy.service';
import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token';
const STORE_SYNC_TIMEOUT_MS = 8_000;
// A request that reached the API through the Vite dev proxy names the client's port, not the API's.
const DEV_CLIENT_PORT = new URL(DEV_CLIENT_ORIGIN).port;
const KOBO_SYNC_TOKEN_HEADER = 'x-kobo-synctoken';
// Kobo answers a delete for a tag it does not know with a client error, and the device keeps the
@@ -88,7 +91,7 @@ function buildReadingServicesBaseUrl(req: FastifyRequest, baseUrl: string): stri
if (!localPort) return baseUrl;
const url = new URL(baseUrl);
if (url.port !== '5173') return baseUrl;
if (url.port !== DEV_CLIENT_PORT) return baseUrl;
const proto = readHeaderValue(req.headers['x-forwarded-proto']) ?? req.protocol;
url.protocol = proto + ':';
@@ -35,11 +35,9 @@ function makeGateway() {
findRunById: vi.fn(),
listRunMetrics: vi.fn(),
};
const configService = {
get: vi.fn().mockReturnValue('http://localhost:5173'),
};
const appConfiguration = { appUrl: 'http://localhost:6263' };
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never, configService as never);
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never, appConfiguration as never);
return { gateway, jwtService, authService, repo };
}
@@ -64,7 +62,7 @@ describe('MigrationProgressGateway', () => {
expect(server.engine.opts.cors).toEqual({
methods: ['GET'],
origin: 'http://localhost:5173',
origin: 'http://localhost:6263',
credentials: true,
});
});
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@@ -11,6 +11,7 @@ import { AuthService } from '../auth/auth.service';
import { MigrationRepository } from './migration.repository';
import { sanitizeRunForApi } from './core/api-sanitizers';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -22,9 +23,9 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
private readonly jwtService: JwtService,
private readonly authService: AuthService,
private readonly repo: MigrationRepository,
config: ConfigService,
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
this.clientOrigin = app.appUrl;
}
afterInit(server: Server): void {
@@ -6,7 +6,7 @@ import { NotificationGateway } from './notification.gateway';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
function makeGateway() {
return new NotificationGateway({} as never, {} as never, {} as never, { get: vi.fn().mockReturnValue('http://localhost:5173') } as never);
return new NotificationGateway({} as never, {} as never, {} as never, { appUrl: 'http://localhost:6263' } as never);
}
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
@@ -84,7 +84,7 @@ describe('NotificationGateway handshake rejection', () => {
};
const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) };
const notificationRepo = { countUnread: vi.fn().mockResolvedValue(0) };
const config = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const config = { appUrl: 'http://localhost:6263' };
const gateway = new NotificationGateway(jwtService as never, userService as never, notificationRepo as never, config as never);
const client = { id: 'sock-1', handshake: { auth: { token: 'jwt' } }, data: {}, join: vi.fn(), emit: vi.fn(), disconnect: vi.fn() };
return { gateway, client };
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Permission } from '@bookorbit/types';
@@ -11,6 +11,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { UserService } from '../user/user.service';
import { NotificationRepository } from './notification.repository';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -22,9 +23,9 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
private readonly jwtService: JwtService,
private readonly userService: UserService,
private readonly notificationRepo: NotificationRepository,
config: ConfigService,
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
this.clientOrigin = app.appUrl;
}
afterInit(server: Server): void {
@@ -59,7 +59,7 @@ describe('podcast event stream over HTTP', () => {
events = app.get(PodcastEventsService);
socketEmit = vi.fn();
gateway = new PodcastGateway({} as never, {} as never, {} as never, events, { get: vi.fn().mockReturnValue('http://localhost:5173') } as never);
gateway = new PodcastGateway({} as never, {} as never, {} as never, events, { appUrl: 'http://localhost:6263' } as never);
gateway['server'] = { to: vi.fn().mockReturnValue({ emit: socketEmit }) } as never;
});
@@ -13,7 +13,7 @@ function makeGateway() {
publishImportProgress: vi.fn(),
publishRetentionEvicted: vi.fn(),
};
const config = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const config = { appUrl: 'http://localhost:6263' };
const gateway = new PodcastGateway(jwtService as never, authService as never, libraries as never, events as never, config as never);
return { gateway, jwtService, authService, libraries, events };
}
@@ -1,5 +1,5 @@
import { Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Inject, Logger, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@@ -19,6 +19,7 @@ import { AuthService } from '../auth/auth.service';
import { LibraryService } from '../library/library.service';
import { PodcastEventsService } from './podcast-events.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/podcasts', cors: { credentials: true } })
export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -31,9 +32,9 @@ export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGat
private readonly authService: AuthService,
private readonly libraries: LibraryService,
private readonly events: PodcastEventsService,
config: ConfigService,
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
this.clientOrigin = app.appUrl;
}
afterInit(server: Server): void {
@@ -8,8 +8,8 @@ function makeGateway() {
const authService = { validateSessionUser: vi.fn() };
const scanJobStore = { get: vi.fn(), isRunning: vi.fn() };
const achievementEvents = new AchievementEventsService();
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') };
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, configService as any);
const appConfiguration = { appUrl: 'http://localhost:6263' };
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, appConfiguration as any);
return { gateway, jwtService, authService, scanJobStore, achievementEvents };
}
@@ -180,7 +180,7 @@ describe('subscription lifecycle', () => {
expect(server.engine.opts.cors).toEqual({
methods: ['GET'],
origin: 'http://localhost:5173',
origin: 'http://localhost:6263',
credentials: true,
});
});
+5 -4
View File
@@ -1,5 +1,5 @@
import { Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Inject, Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@@ -25,6 +25,7 @@ import {
} from '../achievement/achievement-events.service';
import { ScanJobStore } from './scan-job-store.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy {
@@ -40,9 +41,9 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private readonly authService: AuthService,
private readonly scanJobStore: ScanJobStore,
private readonly achievementEvents: AchievementEventsService,
config: ConfigService,
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
this.clientOrigin = app.appUrl;
}
onModuleInit(): void {
+19 -12
View File
@@ -52,7 +52,7 @@ describe('UserService', () => {
replaceFilters: vi.fn(),
};
const config = { get: vi.fn() };
const appConfiguration = { appUrl: '' };
const appSettingsService = {
getDefaultLibraryAccessLibraryIds: vi.fn(),
};
@@ -68,13 +68,21 @@ describe('UserService', () => {
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
events = new UserEventsService();
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any, userStatistics as any, events, {
service = new UserService(
userRepo as any,
appConfiguration as any,
contentFilterRepo as any,
appSettingsService as any,
userStatistics as any,
events,
{
assertPasswordLoginEnabled: vi.fn(),
} as any);
} as any,
);
mockHash.mockResolvedValue('hashed-secret');
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
config.get.mockReturnValue('https://app.example.com');
appConfiguration.appUrl = 'https://app.example.com';
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' });
@@ -196,7 +204,6 @@ describe('UserService', () => {
it('createUser skips permission/library writes when lists are empty', async () => {
userRepo.findByUsername.mockResolvedValue(null);
config.get.mockReturnValue(undefined);
const result = await service.createUser({
username: 'newuser',
@@ -208,7 +215,7 @@ describe('UserService', () => {
expect(userRepo.setPermissions).not.toHaveBeenCalled();
expect(userRepo.assignViewerLibraries).not.toHaveBeenCalled();
expect(result.resetUrl).toBe('http://localhost:5173/reset-password?token=reset-token');
expect(result.resetUrl).toBe('https://app.example.com/reset-password?token=reset-token');
});
it('findById returns user and throws when missing', async () => {
@@ -775,13 +782,13 @@ describe('UserService', () => {
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
});
it('adminResetPassword returns default app URL when config is missing', async () => {
it('adminResetPassword builds the reset link from the configured app URL', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'local' });
userRepo.generateResetToken.mockResolvedValue('token-2');
config.get.mockReturnValue(undefined);
appConfiguration.appUrl = 'http://localhost:6263';
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).resolves.toEqual({
resetUrl: 'http://localhost:5173/reset-password?token=token-2',
resetUrl: 'http://localhost:6263/reset-password?token=token-2',
});
});
});
@@ -814,7 +821,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
findByUserIdWithNames: vi.fn(),
replaceFilters: vi.fn(),
};
const config = { get: vi.fn() };
const appConfiguration = { appUrl: '' };
const appSettingsService = {
getDefaultLibraryAccessLibraryIds: vi.fn(),
};
@@ -824,11 +831,11 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
beforeEach(() => {
vi.resetAllMocks();
config.get.mockReturnValue('http://localhost:5173');
appConfiguration.appUrl = 'http://localhost:6263';
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
service = new UserService(
userRepo as any,
config as any,
appConfiguration as any,
contentFilterRepo as any,
appSettingsService as any,
userStatistics as any,
+6 -5
View File
@@ -1,5 +1,5 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto';
import { Permission } from '@bookorbit/types';
@@ -22,6 +22,7 @@ import { UserRepository, type UserListQuery } from './user.repository';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { UserStatisticsService } from '../user-statistics/user-statistics.service';
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
import { appConfig } from '../../config/config';
/** The band is a to-do list, not a second roster. */
const ATTENTION_BAND_LIMIT = 8;
@@ -33,7 +34,7 @@ export class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly config: ConfigService,
@Inject(appConfig.KEY) private readonly appConfiguration: ConfigType<typeof appConfig>,
private readonly contentFilterRepo: ContentFilterRepository,
private readonly appSettingsService: AppSettingsService,
private readonly userStatistics: UserStatisticsService,
@@ -158,7 +159,7 @@ export class UserService {
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
}
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
const appUrl = this.appConfiguration.appUrl;
const rawToken = await this.userRepo.generateResetToken(user.id);
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
@@ -439,7 +440,7 @@ export class UserService {
if (target.provisioningMethod === 'shared') {
throw new BadRequestException('Shared accounts do not have passwords');
}
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
const appUrl = this.appConfiguration.appUrl;
const rawToken = await this.userRepo.generateResetToken(targetUserId);
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
}
+2 -2
View File
@@ -25,7 +25,7 @@ const REQUEST_DELAY_MS = Number.parseInt(process.env.BENCH_REQUEST_DELAY_MS ?? '
const MAX_429_RETRIES = Number.parseInt(process.env.BENCH_MAX_429_RETRIES ?? '2', 10);
const OUTPUT_PATH = process.env.BENCH_OUTPUT_PATH ?? '';
const BENCH_BASE_URL = process.env.BENCH_BASE_URL;
const CANDIDATE_BASE_URLS = ['http://localhost:3010', 'http://localhost:3000'];
const CANDIDATE_BASE_URLS = ['http://localhost:6262', 'http://localhost:3010', 'http://localhost:3000'];
const CASES: BenchCase[] = [
{ name: 'summary', path: '/api/v1/user-statistics/summary' },
@@ -79,7 +79,7 @@ async function resolveBaseUrl(): Promise<string> {
if (await isHealthy(candidate)) return candidate;
}
throw new Error('Could not detect a running API base URL. Set BENCH_BASE_URL (for example: http://localhost:3000).');
throw new Error('Could not detect a running API base URL. Set BENCH_BASE_URL (for example: http://localhost:6262).');
}
async function getBenchUser(): Promise<{ id: number; tokenVersion: number }> {
@@ -579,7 +579,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
const callbackPayload = {
code: 'authorization-code-provisioned',
codeVerifier: 'verifier-provisioned',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-provisioned',
state,
};
@@ -691,7 +691,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: {
code: 'authorization-code-linked',
codeVerifier: 'verifier-linked',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-linked',
state,
},
@@ -800,7 +800,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: {
code: 'authorization-code-invalid-state',
codeVerifier: 'verifier-invalid-state',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-invalid-state',
state: `invalid-${randomUUID()}`,
},
@@ -822,7 +822,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: {
code: 'authorization-code-missing-subject',
codeVerifier: 'verifier-missing-subject',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-missing-subject',
state,
},
@@ -864,7 +864,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: {
code: 'authorization-code-disabled',
codeVerifier: 'verifier-disabled',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-disabled',
state: disabledState,
},
@@ -903,7 +903,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: {
code: 'authorization-code-inactive',
codeVerifier: 'verifier-inactive',
redirectUri: 'http://localhost:5173/oauth2-callback',
redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-inactive',
state: inactiveState,
},
@@ -769,7 +769,7 @@ describe('Auth recovery and OIDC logout hardening (e2e)', () => {
url: '/api/v1/auth/logout',
headers: {
cookie: cookieHeader(session.jar),
origin: 'http://localhost:5173',
origin: 'http://localhost:6263',
},
});
expect(logoutResponse.statusCode).toBe(200);