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_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d JWT_REFRESH_EXPIRES_IN: 7d
BOOKS_PATH: ../local/data BOOKS_PATH: ../local/data
APP_URL: http://localhost:5173 APP_URL: http://localhost:6263
jobs: jobs:
run-suite: 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). 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 ## IDE setup
+3 -1
View File
@@ -9,7 +9,7 @@ import { VitePWA } from 'vite-plugin-pwa'
const apiAgent = new Agent({ keepAlive: true }) 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. */ /** 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -141,6 +141,8 @@ export default defineConfig({
exclude: ['@embedpdf/core', '@embedpdf/core/vue'], exclude: ['@embedpdf/core', '@embedpdf/core/vue'],
}, },
server: { server: {
port: 6263,
strictPort: true,
host: true, host: true,
allowedHosts: true, allowedHosts: true,
proxy: { 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 ```mermaid
graph LR graph LR
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:5173</small>"] Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:6263</small>"]
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:3000/api</small>"] Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:6262/api</small>"]
DB["PostgreSQL<br/><small>Drizzle ORM</small>"] DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
WS["WebSocket<br/><small>Socket.IO</small>"] WS["WebSocket<br/><small>Socket.IO</small>"]
Types["Shared Types<br/><small>@bookorbit/types</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 | | Check | Expected |
| ---------------------------------------- | ------------------------------------------ | | ---------------------------------------- | ------------------------------------------ |
| Open http://localhost:5173 | BookOrbit login page loads | | Open http://localhost:6263 | BookOrbit login page loads |
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` | | Open http://localhost:6262/api/v1/health | Returns `{"status":"ok"}` |
| `docker ps` | Shows `bookorbit-dev-db` container running | | `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 | | Service | URL |
| ------- | ------------------------- | | ------- | ------------------------- |
| Client | http://localhost:5173 | | Client | http://localhost:6263 |
| API | http://localhost:3000/api | | API | http://localhost:6262/api |
If PostgreSQL is not running, start it first with `pnpm run db:up`. If PostgreSQL is not running, start it first with `pnpm run db:up`.
@@ -195,8 +195,8 @@ When enabled, open:
| Docs | URL | | Docs | URL |
| ------------ | ----------------------------------- | | ------------ | ----------------------------------- |
| Swagger UI | http://localhost:3000/api/docs | | Swagger UI | http://localhost:6262/api/docs |
| OpenAPI JSON | http://localhost:3000/api/docs-json | | OpenAPI JSON | http://localhost:6262/api/docs-json |
<details> <details>
<summary>Running workspaces separately</summary> <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 | | `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` | | `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` | | `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` | | `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` | | `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
@@ -471,7 +471,7 @@ pnpm run e2e:run -- <suite-id>
**Port already in use** **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** **Hot reload not picking up changes**
+2 -2
View File
@@ -2,7 +2,7 @@
DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
# App # App
PORT=3000 PORT=6262
# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0. # 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. # Use 127.0.0.1 for local-only native deployments.
# HOST=0.0.0.0 # HOST=0.0.0.0
@@ -92,7 +92,7 @@ APP_DATA_PATH=../local/data
# DISABLE_LOCAL_AUTH=false # DISABLE_LOCAL_AUTH=false
# App URL (used for building password reset links in emails) # 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) # Email encryption key (optional but recommended - 64-char hex string for AES-256)
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
+2 -2
View File
@@ -20,8 +20,8 @@ SWAGGER_ENABLED=true pnpm start:dev
Then open: Then open:
- Swagger UI: http://localhost:3000/api/docs - Swagger UI: http://localhost:6262/api/docs
- OpenAPI JSON: http://localhost:3000/api/docs-json - OpenAPI JSON: http://localhost:6262/api/docs-json
Dev watch uses a Nest config without the Swagger metadata generator so the 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 server does not restart immediately after startup. Full build output still uses
@@ -12,7 +12,7 @@ afterEach(() => {
describe('wsCorsOrigin', () => { describe('wsCorsOrigin', () => {
it('falls back to the dev client when nothing is configured', () => { it('falls back to the dev client when nothing is configured', () => {
delete process.env.CLIENT_URL; 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', () => { 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', () => { it('falls back rather than throwing on a malformed value', () => {
process.env.CLIENT_URL = 'not a url'; 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. */ import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
const DEV_CLIENT_ORIGIN = 'http://localhost:5173';
/** /**
* The origin a socket gateway's CORS check allows. * The origin a socket gateway's CORS check allows.
+1 -1
View File
@@ -64,7 +64,7 @@ describe('config', () => {
expect(appConfig()).toEqual({ expect(appConfig()).toEqual({
nodeEnv: 'development', nodeEnv: 'development',
host: '0.0.0.0', host: '0.0.0.0',
appUrl: 'http://localhost:5173', appUrl: 'http://localhost:6263',
nativeRedirectUri: 'bookorbit://oauth2-callback', nativeRedirectUri: 'bookorbit://oauth2-callback',
version: 'Local build', version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit', githubReleasesRepo: 'bookorbit/bookorbit',
+2 -1
View File
@@ -1,10 +1,11 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
export const appConfig = registerAs('app', () => ({ export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST?.trim() || '0.0.0.0', 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 // 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 // client binary, so it is the same for every provider on every deployment; configurable only so
// a rebranded fork can change it. // 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 { 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 BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', '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_METADATA_BYTES: z.coerce.number().int().positive().optional(),
AUDIOLESS_EPUB_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().optional(), AUDIOLESS_EPUB_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().optional(),
CLIENT_URL: z.string().url().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(), TRUST_PROXY: trustProxyEnv(),
EMAIL_ENCRYPTION_KEY: z.string().optional(), EMAIL_ENCRYPTION_KEY: z.string().optional(),
MIGRATION_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 fastifyCompress from '@fastify/compress';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { appConfig } from './config/config'; import { appConfig } from './config/config';
import { DEV_CLIENT_ORIGIN } from './config/dev-client-origin';
import { setupSwaggerDocs } from './swagger'; import { setupSwaggerDocs } from './swagger';
import { import {
parseBooleanEnv, parseBooleanEnv,
@@ -93,7 +94,7 @@ async function bootstrap() {
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
app.enableCors({ app.enableCors({
origin: process.env.CLIENT_URL ?? 'http://localhost:5173', origin: process.env.CLIENT_URL ?? DEV_CLIENT_ORIGIN,
credentials: true, credentials: true,
}); });
} }
@@ -2,7 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { OidcService } from './oidc.service'; 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 VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
const NATIVE_REDIRECT_URI = 'bookorbit://oauth2-callback'; const NATIVE_REDIRECT_URI = 'bookorbit://oauth2-callback';
@@ -82,13 +82,7 @@ function makeService() {
const auditEvents = { const auditEvents = {
emit: vi.fn(), emit: vi.fn(),
}; };
const configService = { const appConfiguration = { appUrl: APP_URL, nativeRedirectUri: NATIVE_REDIRECT_URI };
get: vi.fn().mockImplementation((key: string) => {
if (key === 'app.appUrl') return APP_URL;
if (key === 'app.nativeRedirectUri') return NATIVE_REDIRECT_URI;
return undefined;
}),
};
const authenticationPolicy = { const authenticationPolicy = {
isPasswordLoginEnabled: vi.fn().mockReturnValue(true), isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
}; };
@@ -106,7 +100,7 @@ function makeService() {
userService as never, userService as never,
authService as never, authService as never,
auditEvents as never, auditEvents as never,
configService as never, appConfiguration as never,
authenticationPolicy as never, authenticationPolicy as never,
); );
+6 -5
View File
@@ -1,6 +1,6 @@
import { OidcCallbackDto } from '../dto/oidc-callback.dto'; import { OidcCallbackDto } from '../dto/oidc-callback.dto';
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { compare } from 'bcryptjs'; import { compare } from 'bcryptjs';
import type { FastifyReply } from 'fastify'; import type { FastifyReply } from 'fastify';
import { AuditAction, AuditResource, AuthenticationMethod, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types'; 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 { OidcTokenClientService } from './oidc-token-client.service';
import { OidcTokenValidatorService } from './oidc-token-validator.service'; import { OidcTokenValidatorService } from './oidc-token-validator.service';
import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service'; import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service';
import { appConfig } from '../../../config/config';
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false; if (!error || typeof error !== 'object') return false;
@@ -53,11 +54,11 @@ export class OidcService {
private readonly userService: UserService, private readonly userService: UserService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly auditEvents: AuditEventsService, private readonly auditEvents: AuditEventsService,
private readonly configService: ConfigService, @Inject(appConfig.KEY) appConfiguration: ConfigType<typeof appConfig>,
private readonly authenticationPolicy: AuthenticationPolicyService, private readonly authenticationPolicy: AuthenticationPolicyService,
) { ) {
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, ''); this.appUrl = appConfiguration.appUrl.replace(/\/$/, '');
this.nativeRedirectUri = this.configService.get<string>('app.nativeRedirectUri') ?? 'bookorbit://oauth2-callback'; this.nativeRedirectUri = appConfiguration.nativeRedirectUri;
} }
async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> { async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
@@ -1,22 +1,22 @@
import { isAllowedRedirectUri } from './redirect-uri'; import { isAllowedRedirectUri } from './redirect-uri';
const POLICY = { const POLICY = {
appUrl: 'http://localhost:5173', appUrl: 'http://localhost:6263',
nativeRedirectUri: 'bookorbit://oauth2-callback', nativeRedirectUri: 'bookorbit://oauth2-callback',
}; };
describe('isAllowedRedirectUri', () => { describe('isAllowedRedirectUri', () => {
describe('web redirect URI', () => { describe('web redirect URI', () => {
it('accepts the exact web callback', () => { 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', () => { 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', () => { 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', () => { it('rejects a different origin', () => {
@@ -24,7 +24,7 @@ describe('isAllowedRedirectUri', () => {
}); });
it('rejects a different path on the right origin', () => { 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 { 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; appUrl: string;
/** Private-use scheme the native clients hand to the IdP, e.g. `bookorbit://oauth2-callback`. */ /** Private-use scheme the native clients hand to the IdP, e.g. `bookorbit://oauth2-callback`. */
nativeRedirectUri: string; nativeRedirectUri: string;
@@ -9,7 +9,7 @@ function makeGateway() {
const queueRepo = { getStatusSummary: vi.fn() }; const queueRepo = { getStatusSummary: vi.fn() };
const enrichmentConfig = { isPaused: vi.fn() }; const enrichmentConfig = { isPaused: vi.fn() };
const session = { getSnapshot: 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( const gateway = new AuthorEnrichmentGateway(
jwtService as any, jwtService as any,
@@ -17,7 +17,7 @@ function makeGateway() {
queueRepo as any, queueRepo as any,
enrichmentConfig as any, enrichmentConfig as any,
session as any, session as any,
configService as any, appConfiguration as any,
); );
return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session }; return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session };
@@ -90,7 +90,7 @@ describe('AuthorEnrichmentGateway', () => {
expect(server.engine.opts.cors).toEqual({ expect(server.engine.opts.cors).toEqual({
methods: ['GET'], methods: ['GET'],
origin: 'http://localhost:5173', origin: 'http://localhost:6263',
credentials: true, credentials: true,
}); });
}); });
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Permission, type AuthenticationMethod, type AuthorEnrichmentStatusEvent } from '@bookorbit/types'; 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 { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service'; import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils'; import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
export const AUTHOR_ENRICHMENT_STATUS_EVENT = 'author-enrichment:status'; 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 queueRepo: AuthorEnrichmentRepository,
private readonly enrichmentConfig: AuthorEnrichmentConfigService, private readonly enrichmentConfig: AuthorEnrichmentConfigService,
private readonly session: AuthorEnrichmentSessionService, 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 { afterInit(server: Server): void {
@@ -96,7 +96,7 @@ describe('KoboSyncController', () => {
const req = { const req = {
headers: { headers: {
'x-forwarded-host': '192.168.8.134', 'x-forwarded-host': '192.168.8.134',
'x-forwarded-port': '5173', 'x-forwarded-port': '6263',
'x-forwarded-proto': 'http', 'x-forwarded-proto': 'http',
host: '192.168.8.134', host: '192.168.8.134',
}, },
@@ -108,9 +108,9 @@ describe('KoboSyncController', () => {
const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never); const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never);
const resources = payload.Resources as Record<string, string>; const resources = payload.Resources as Record<string, string>;
expect(resources.image_host).toBe('http://192.168.8.134:5173'); expect(resources.image_host).toBe('http://192.168.8.134:6263');
expect(resources.library_sync).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/sync'); 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:5173/api/v1/kobo/dev/v1/library/{Ids}/state'); 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', () => { 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 { KoboReadingStateService } from './services/kobo-reading-state.service';
import type { KoboProxyResponse } from './services/kobo-proxy.service'; import type { KoboProxyResponse } from './services/kobo-proxy.service';
import { KoboProxyService } 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 { KOBO_STORE_RESOURCES } from './kobo-store-resources';
import { KoboBookIdentityService } from './services/kobo-book-identity.service'; import { KoboBookIdentityService } from './services/kobo-book-identity.service';
import { KoboSyncHistoryService } from './services/kobo-sync-history.service'; import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token'; import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token';
const STORE_SYNC_TIMEOUT_MS = 8_000; 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'; 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 // 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; if (!localPort) return baseUrl;
const url = new URL(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; const proto = readHeaderValue(req.headers['x-forwarded-proto']) ?? req.protocol;
url.protocol = proto + ':'; url.protocol = proto + ':';
@@ -35,11 +35,9 @@ function makeGateway() {
findRunById: vi.fn(), findRunById: vi.fn(),
listRunMetrics: vi.fn(), listRunMetrics: vi.fn(),
}; };
const configService = { const appConfiguration = { appUrl: 'http://localhost:6263' };
get: vi.fn().mockReturnValue('http://localhost:5173'),
};
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 }; return { gateway, jwtService, authService, repo };
} }
@@ -64,7 +62,7 @@ describe('MigrationProgressGateway', () => {
expect(server.engine.opts.cors).toEqual({ expect(server.engine.opts.cors).toEqual({
methods: ['GET'], methods: ['GET'],
origin: 'http://localhost:5173', origin: 'http://localhost:6263',
credentials: true, credentials: true,
}); });
}); });
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
@@ -11,6 +11,7 @@ import { AuthService } from '../auth/auth.service';
import { MigrationRepository } from './migration.repository'; import { MigrationRepository } from './migration.repository';
import { sanitizeRunForApi } from './core/api-sanitizers'; import { sanitizeRunForApi } from './core/api-sanitizers';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils'; import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } }) @WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -22,9 +23,9 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly repo: MigrationRepository, 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 { afterInit(server: Server): void {
@@ -6,7 +6,7 @@ import { NotificationGateway } from './notification.gateway';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
function makeGateway() { 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 { function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
@@ -84,7 +84,7 @@ describe('NotificationGateway handshake rejection', () => {
}; };
const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) }; const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) };
const notificationRepo = { countUnread: vi.fn().mockResolvedValue(0) }; 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 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() }; const client = { id: 'sock-1', handshake: { auth: { token: 'jwt' } }, data: {}, join: vi.fn(), emit: vi.fn(), disconnect: vi.fn() };
return { gateway, client }; return { gateway, client };
@@ -1,5 +1,5 @@
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Permission } from '@bookorbit/types'; import { Permission } from '@bookorbit/types';
@@ -11,6 +11,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { NotificationRepository } from './notification.repository'; import { NotificationRepository } from './notification.repository';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils'; import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } }) @WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -22,9 +23,9 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly notificationRepo: NotificationRepository, 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 { afterInit(server: Server): void {
@@ -59,7 +59,7 @@ describe('podcast event stream over HTTP', () => {
events = app.get(PodcastEventsService); events = app.get(PodcastEventsService);
socketEmit = vi.fn(); 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; gateway['server'] = { to: vi.fn().mockReturnValue({ emit: socketEmit }) } as never;
}); });
@@ -13,7 +13,7 @@ function makeGateway() {
publishImportProgress: vi.fn(), publishImportProgress: vi.fn(),
publishRetentionEvicted: 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); 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 }; return { gateway, jwtService, authService, libraries, events };
} }
@@ -1,5 +1,5 @@
import { Logger, UnauthorizedException } from '@nestjs/common'; import { Inject, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
@@ -19,6 +19,7 @@ import { AuthService } from '../auth/auth.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
import { PodcastEventsService } from './podcast-events.service'; import { PodcastEventsService } from './podcast-events.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils'; import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/podcasts', cors: { credentials: true } }) @WebSocketGateway({ namespace: '/podcasts', cors: { credentials: true } })
export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@@ -31,9 +32,9 @@ export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGat
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly libraries: LibraryService, private readonly libraries: LibraryService,
private readonly events: PodcastEventsService, 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 { afterInit(server: Server): void {
@@ -8,8 +8,8 @@ function makeGateway() {
const authService = { validateSessionUser: vi.fn() }; const authService = { validateSessionUser: vi.fn() };
const scanJobStore = { get: vi.fn(), isRunning: vi.fn() }; const scanJobStore = { get: vi.fn(), isRunning: vi.fn() };
const achievementEvents = new AchievementEventsService(); const achievementEvents = new AchievementEventsService();
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') }; const appConfiguration = { appUrl: 'http://localhost:6263' };
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, configService as any); const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, appConfiguration as any);
return { gateway, jwtService, authService, scanJobStore, achievementEvents }; return { gateway, jwtService, authService, scanJobStore, achievementEvents };
} }
@@ -180,7 +180,7 @@ describe('subscription lifecycle', () => {
expect(server.engine.opts.cors).toEqual({ expect(server.engine.opts.cors).toEqual({
methods: ['GET'], methods: ['GET'],
origin: 'http://localhost:5173', origin: 'http://localhost:6263',
credentials: true, credentials: true,
}); });
}); });
+5 -4
View File
@@ -1,5 +1,5 @@
import { Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common'; import { Inject, Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
@@ -25,6 +25,7 @@ import {
} from '../achievement/achievement-events.service'; } from '../achievement/achievement-events.service';
import { ScanJobStore } from './scan-job-store.service'; import { ScanJobStore } from './scan-job-store.service';
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils'; import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
import { appConfig } from '../../config/config';
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } }) @WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy { 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 authService: AuthService,
private readonly scanJobStore: ScanJobStore, private readonly scanJobStore: ScanJobStore,
private readonly achievementEvents: AchievementEventsService, 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 { onModuleInit(): void {
+19 -12
View File
@@ -52,7 +52,7 @@ describe('UserService', () => {
replaceFilters: vi.fn(), replaceFilters: vi.fn(),
}; };
const config = { get: vi.fn() }; const appConfiguration = { appUrl: '' };
const appSettingsService = { const appSettingsService = {
getDefaultLibraryAccessLibraryIds: vi.fn(), getDefaultLibraryAccessLibraryIds: vi.fn(),
}; };
@@ -68,13 +68,21 @@ describe('UserService', () => {
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
events = new UserEventsService(); 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(), assertPasswordLoginEnabled: vi.fn(),
} as any); } as any,
);
mockHash.mockResolvedValue('hashed-secret'); mockHash.mockResolvedValue('hashed-secret');
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex')); mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
config.get.mockReturnValue('https://app.example.com'); appConfiguration.appUrl = 'https://app.example.com';
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]); appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' }); 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 () => { it('createUser skips permission/library writes when lists are empty', async () => {
userRepo.findByUsername.mockResolvedValue(null); userRepo.findByUsername.mockResolvedValue(null);
config.get.mockReturnValue(undefined);
const result = await service.createUser({ const result = await service.createUser({
username: 'newuser', username: 'newuser',
@@ -208,7 +215,7 @@ describe('UserService', () => {
expect(userRepo.setPermissions).not.toHaveBeenCalled(); expect(userRepo.setPermissions).not.toHaveBeenCalled();
expect(userRepo.assignViewerLibraries).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 () => { 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); 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.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'local' });
userRepo.generateResetToken.mockResolvedValue('token-2'); userRepo.generateResetToken.mockResolvedValue('token-2');
config.get.mockReturnValue(undefined); appConfiguration.appUrl = 'http://localhost:6263';
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).resolves.toEqual({ 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(), findByUserIdWithNames: vi.fn(),
replaceFilters: vi.fn(), replaceFilters: vi.fn(),
}; };
const config = { get: vi.fn() }; const appConfiguration = { appUrl: '' };
const appSettingsService = { const appSettingsService = {
getDefaultLibraryAccessLibraryIds: vi.fn(), getDefaultLibraryAccessLibraryIds: vi.fn(),
}; };
@@ -824,11 +831,11 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
config.get.mockReturnValue('http://localhost:5173'); appConfiguration.appUrl = 'http://localhost:6263';
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]); appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
service = new UserService( service = new UserService(
userRepo as any, userRepo as any,
config as any, appConfiguration as any,
contentFilterRepo as any, contentFilterRepo as any,
appSettingsService as any, appSettingsService as any,
userStatistics as any, userStatistics as any,
+6 -5
View File
@@ -1,5 +1,5 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Permission } from '@bookorbit/types'; 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 { AppSettingsService } from '../app-settings/app-settings.service';
import { UserStatisticsService } from '../user-statistics/user-statistics.service'; import { UserStatisticsService } from '../user-statistics/user-statistics.service';
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.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. */ /** The band is a to-do list, not a second roster. */
const ATTENTION_BAND_LIMIT = 8; const ATTENTION_BAND_LIMIT = 8;
@@ -33,7 +34,7 @@ export class UserService {
constructor( constructor(
private readonly userRepo: UserRepository, private readonly userRepo: UserRepository,
private readonly config: ConfigService, @Inject(appConfig.KEY) private readonly appConfiguration: ConfigType<typeof appConfig>,
private readonly contentFilterRepo: ContentFilterRepository, private readonly contentFilterRepo: ContentFilterRepository,
private readonly appSettingsService: AppSettingsService, private readonly appSettingsService: AppSettingsService,
private readonly userStatistics: UserStatisticsService, private readonly userStatistics: UserStatisticsService,
@@ -158,7 +159,7 @@ export class UserService {
await this.userRepo.assignViewerLibraries(user.id, libraryIds); 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 rawToken = await this.userRepo.generateResetToken(user.id);
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`; const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
@@ -439,7 +440,7 @@ export class UserService {
if (target.provisioningMethod === 'shared') { if (target.provisioningMethod === 'shared') {
throw new BadRequestException('Shared accounts do not have passwords'); 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); const rawToken = await this.userRepo.generateResetToken(targetUserId);
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` }; 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 MAX_429_RETRIES = Number.parseInt(process.env.BENCH_MAX_429_RETRIES ?? '2', 10);
const OUTPUT_PATH = process.env.BENCH_OUTPUT_PATH ?? ''; const OUTPUT_PATH = process.env.BENCH_OUTPUT_PATH ?? '';
const BENCH_BASE_URL = process.env.BENCH_BASE_URL; 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[] = [ const CASES: BenchCase[] = [
{ name: 'summary', path: '/api/v1/user-statistics/summary' }, { name: 'summary', path: '/api/v1/user-statistics/summary' },
@@ -79,7 +79,7 @@ async function resolveBaseUrl(): Promise<string> {
if (await isHealthy(candidate)) return candidate; 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 }> { async function getBenchUser(): Promise<{ id: number; tokenVersion: number }> {
@@ -579,7 +579,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
const callbackPayload = { const callbackPayload = {
code: 'authorization-code-provisioned', code: 'authorization-code-provisioned',
codeVerifier: 'verifier-provisioned', codeVerifier: 'verifier-provisioned',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-provisioned', nonce: 'nonce-provisioned',
state, state,
}; };
@@ -691,7 +691,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: { payload: {
code: 'authorization-code-linked', code: 'authorization-code-linked',
codeVerifier: 'verifier-linked', codeVerifier: 'verifier-linked',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-linked', nonce: 'nonce-linked',
state, state,
}, },
@@ -800,7 +800,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: { payload: {
code: 'authorization-code-invalid-state', code: 'authorization-code-invalid-state',
codeVerifier: 'verifier-invalid-state', codeVerifier: 'verifier-invalid-state',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-invalid-state', nonce: 'nonce-invalid-state',
state: `invalid-${randomUUID()}`, state: `invalid-${randomUUID()}`,
}, },
@@ -822,7 +822,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: { payload: {
code: 'authorization-code-missing-subject', code: 'authorization-code-missing-subject',
codeVerifier: 'verifier-missing-subject', codeVerifier: 'verifier-missing-subject',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-missing-subject', nonce: 'nonce-missing-subject',
state, state,
}, },
@@ -864,7 +864,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: { payload: {
code: 'authorization-code-disabled', code: 'authorization-code-disabled',
codeVerifier: 'verifier-disabled', codeVerifier: 'verifier-disabled',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-disabled', nonce: 'nonce-disabled',
state: disabledState, state: disabledState,
}, },
@@ -903,7 +903,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
payload: { payload: {
code: 'authorization-code-inactive', code: 'authorization-code-inactive',
codeVerifier: 'verifier-inactive', codeVerifier: 'verifier-inactive',
redirectUri: 'http://localhost:5173/oauth2-callback', redirectUri: 'http://localhost:6263/oauth2-callback',
nonce: 'nonce-inactive', nonce: 'nonce-inactive',
state: inactiveState, state: inactiveState,
}, },
@@ -769,7 +769,7 @@ describe('Auth recovery and OIDC logout hardening (e2e)', () => {
url: '/api/v1/auth/logout', url: '/api/v1/auth/logout',
headers: { headers: {
cookie: cookieHeader(session.jar), cookie: cookieHeader(session.jar),
origin: 'http://localhost:5173', origin: 'http://localhost:6263',
}, },
}); });
expect(logoutResponse.statusCode).toBe(200); expect(logoutResponse.statusCode).toBe(200);