diff --git a/server/CLAUDE.md b/server/CLAUDE.md index f815c2a53..f13e9cca7 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -97,6 +97,7 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s - **Logging**: `src/nest/audit/audit-log.logger.ts` (`logInfo`/`logWarn`/`logError`/`logDebug`) prints each line to the console and queues it for `data/logs/trek.log`, which `log-file.ts` appends in batches off the event loop and rotates at 10 MB over five files; `index.ts` flushes what is queued on exit. Never write the log file synchronously from a request path. - **Request correlation** (`src/nest/common/request-correlation.ts`): every HTTP request runs under an id (a well-formed incoming `X-Request-Id`, else a fresh UUID) that the response carries in `X-Request-Id`, and every log line written inside it is tagged `[http ]`. The exception filter leaves a 5xx on `res.locals` so the access log writes the request line and the stack as one entry. The other entry points (MCP tool/resource/prompt calls via the registry's `around` seam, WebSocket messages in `TrekWsAdapter`, plugin RPC dispatches in the supervisor, cron ticks in `CronRegistrarService`) each run under their own id through `traceEntry()` (`nest/audit/entry-trace.logger.ts`), which logs one line per call: debug on success, warn or error with the reason on failure. A new entry point goes through `traceEntry()` too. - **Process safety nets** (`src/index.ts`, `src/shutdown.ts`): `unhandledRejection` and `uncaughtException` are logged to trek.log and end in the same orderly shutdown as SIGTERM, with exit code 1; a second fatal error during it exits at once. A shutdown first marks `ReadinessService` (`nest/health/`) as draining, so `/api/health/ready` answers 503 for the whole drain. The HTTP server keeps idle keep-alive connections for `HTTP_KEEP_ALIVE_TIMEOUT_MS` (65 s, above the usual proxy idle timeout) with the headers timeout one second above it (`applyProxyTimeouts` in `bootstrap.ts`). +- **Process-local state sits behind injectable ports**, each with an in-memory implementation that keeps today's behaviour: `RateLimitStore` (`nest/common/rate-limit.store.ts`, provided by `RateLimitModule`), `PendingCodeStore` (`nest/oauth/oauth.pending-codes.ts`), `OidcFlowStore` (`nest/oidc/oidc-flow.store.ts`), `PermissionsCacheStore` (`nest/permissions/permissions-cache.ts`) and `RoomRegistry` (`nest/realtime/ws-state.ts`). The owning module provides the implementation, and a hand-built consumer falls back to the same in-memory one, so a store shared between processes replaces a provider rather than the consumers. New process-local state follows the same shape; TREK still runs as one process. - **`src/middleware/globalMiddleware.ts`** is the only Express middleware left (helmet/CSP, CORS, HSTS, forced-HTTPS, logging, cookies). ## Tests diff --git a/server/src/nest/common/rate-limit.module.ts b/server/src/nest/common/rate-limit.module.ts index 5b78e29f2..1bab16b12 100644 --- a/server/src/nest/common/rate-limit.module.ts +++ b/server/src/nest/common/rate-limit.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { RateLimitService } from './rate-limit.service'; +import { InMemoryRateLimitStore, RateLimitStore } from './rate-limit.store'; /** * One module owning the limiter, imported by every consumer. @@ -13,9 +14,12 @@ import { RateLimitService } from './rate-limit.service'; * Deliberately not `@Global()`: the e2e suites build a container around a single * domain module, where a global that AppModule never pulled in simply isn't there. * An explicit import also states the dependency where it is actually used. + * + * The counters sit behind the RateLimitStore port: in memory today, and a + * store shared between processes would be provided here instead. */ @Module({ - providers: [RateLimitService], + providers: [RateLimitService, { provide: RateLimitStore, useClass: InMemoryRateLimitStore }], exports: [RateLimitService], }) export class RateLimitModule {} diff --git a/server/src/nest/common/rate-limit.service.ts b/server/src/nest/common/rate-limit.service.ts index eae580588..4a879cc13 100644 --- a/server/src/nest/common/rate-limit.service.ts +++ b/server/src/nest/common/rate-limit.service.ts @@ -1,60 +1,25 @@ import { Injectable } from '@nestjs/common'; - -interface Attempt { count: number; first: number } +import { InMemoryRateLimitStore, RateLimitStore } from './rate-limit.store'; /** - * In-memory per-IP rate limiter, ported 1:1 from the legacy auth route's - * `rateLimiter`. Each named bucket keeps its own attempt map; `check` returns - * false once a key exceeds `max` within `windowMs` (the caller answers 429). + * Per-IP rate limiting for the auth-adjacent routes. `check` returns false + * once a key exceeds `max` within `windowMs` (the caller answers 429). * - * The legacy route ran a setInterval to garbage-collect expired records. There - * is no timer here — a dangling one leaks in tests — but the housekeeping is - * back as a lazy sweep driven by the `now` the caller already passes: keys are - * per-IP and proxy-header derived, so without it a bucket grows for every - * address that ever knocked and never shrinks again. An expired record is - * treated as fresh by the window check below, so dropping it changes nothing - * about who gets a 429. + * The counting lives in an injected RateLimitStore. RateLimitModule provides + * the in-memory one; a hand-built service (the no-Nest test harnesses) gets + * its own in-memory store, exactly as it got its own maps before. */ @Injectable() export class RateLimitService { - private readonly buckets = new Map>(); - /** Last sweep per bucket, so a busy bucket doesn't keep a quiet one from being cleaned. */ - private readonly lastSweep = new Map(); - - private store(bucket: string): Map { - let s = this.buckets.get(bucket); - if (!s) { s = new Map(); this.buckets.set(bucket, s); } - return s; - } + constructor(private readonly store: RateLimitStore = new InMemoryRateLimitStore()) {} /** Returns true when the request is allowed, false when it should be rejected (429). */ check(bucket: string, key: string, max: number, windowMs: number, now: number): boolean { - const store = this.store(bucket); - this.sweep(bucket, store, windowMs, now); - const record = store.get(key); - if (record && record.count >= max && now - record.first < windowMs) { - return false; - } - if (!record || now - record.first >= windowMs) { - store.set(key, { count: 1, first: now }); - } else { - record.count++; - } - return true; - } - - /** Drops records whose window has elapsed, at most once per window per bucket. */ - private sweep(bucket: string, store: Map, windowMs: number, now: number): void { - if (now - (this.lastSweep.get(bucket) ?? 0) < windowMs) return; - for (const [k, record] of store) { - if (now - record.first >= windowMs) store.delete(k); - } - this.lastSweep.set(bucket, now); + return this.store.hit(bucket, key, max, windowMs, now); } /** Test helper: clear a bucket (mirrors the legacy exported maps used for resets). */ reset(bucket?: string): void { - if (bucket) this.buckets.get(bucket)?.clear(); - else { this.buckets.clear(); this.lastSweep.clear(); } + this.store.reset(bucket); } } diff --git a/server/src/nest/common/rate-limit.store.ts b/server/src/nest/common/rate-limit.store.ts new file mode 100644 index 000000000..d70643581 --- /dev/null +++ b/server/src/nest/common/rate-limit.store.ts @@ -0,0 +1,88 @@ +/** + * The counters behind RateLimitService, as an injectable port. + * + * Today they live in this process's memory, which is right while TREK runs + * as one process; with several, each would count on its own and a client + * could spread its attempts over them. A store shared between processes + * replaces InMemoryRateLimitStore in RateLimitModule without touching the + * service or its callers. + */ +export abstract class RateLimitStore { + /** + * Count one attempt by `key` in `bucket` at `now`. False when the key has + * already used `max` attempts in the current `windowMs` window (the caller + * answers 429), true otherwise. + */ + abstract hit(bucket: string, key: string, max: number, windowMs: number, now: number): boolean; + /** Clear one bucket, or every bucket. */ + abstract reset(bucket?: string): void; +} + +interface Attempt { + count: number; + first: number; +} + +/** + * The current behaviour, ported 1:1 from the legacy auth route's + * `rateLimiter`: each named bucket keeps its own attempt map. + * + * The legacy route ran a setInterval to garbage-collect expired records. There + * is no timer here (a dangling one leaks in tests) but the housekeeping is + * back as a lazy sweep driven by the `now` the caller already passes: keys are + * per-IP and proxy-header derived, so without it a bucket grows for every + * address that ever knocked and never shrinks again. An expired record is + * treated as fresh by the window check below, so dropping it changes nothing + * about who gets a 429. + */ +export class InMemoryRateLimitStore extends RateLimitStore { + private readonly buckets = new Map>(); + /** Last sweep per bucket, so a busy bucket doesn't keep a quiet one from being cleaned. */ + private readonly lastSweep = new Map(); + + hit(bucket: string, key: string, max: number, windowMs: number, now: number): boolean { + const store = this.store(bucket); + this.sweep(bucket, store, windowMs, now); + const record = store.get(key); + if (record && record.count >= max && now - record.first < windowMs) { + return false; + } + if (!record || now - record.first >= windowMs) { + store.set(key, { count: 1, first: now }); + } else { + record.count++; + } + return true; + } + + reset(bucket?: string): void { + if (bucket) this.buckets.get(bucket)?.clear(); + else { + this.buckets.clear(); + this.lastSweep.clear(); + } + } + + /** How many keys a bucket holds right now; the housekeeping is only visible here. */ + size(bucket: string): number { + return this.buckets.get(bucket)?.size ?? 0; + } + + private store(bucket: string): Map { + let s = this.buckets.get(bucket); + if (!s) { + s = new Map(); + this.buckets.set(bucket, s); + } + return s; + } + + /** Drops records whose window has elapsed, at most once per window per bucket. */ + private sweep(bucket: string, store: Map, windowMs: number, now: number): void { + if (now - (this.lastSweep.get(bucket) ?? 0) < windowMs) return; + for (const [k, record] of store) { + if (now - record.first >= windowMs) store.delete(k); + } + this.lastSweep.set(bucket, now); + } +} diff --git a/server/src/nest/oauth/oauth.module.ts b/server/src/nest/oauth/oauth.module.ts index 6dc6c23e5..35552a1d6 100644 --- a/server/src/nest/oauth/oauth.module.ts +++ b/server/src/nest/oauth/oauth.module.ts @@ -18,6 +18,7 @@ import { OauthClients } from '../../db/entities/OauthClients.entity'; import { OauthTokens } from '../../db/entities/OauthTokens.entity'; import { OauthConsents } from '../../db/entities/OauthConsents.entity'; import { Users } from '../../db/entities/Users.entity'; +import { PendingCodeStore, processPendingCodes } from './oauth.pending-codes'; /** * OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's @@ -36,9 +37,10 @@ import { Users } from '../../db/entities/Users.entity'; * body now parse with qs (extended:true) rather than querystring — the SDK * zod-rejects such bodies either way. * - * Pending authorization codes live module-scoped in oauth.pending-codes.ts: - * the consent controller (container singleton) writes them, the SDK exchange - * path reads them back through the same injected singleton. + * Pending authorization codes live behind the PendingCodeStore port + * (oauth.pending-codes.ts), provided here as the process-wide in-memory + * instance: the consent controller (container singleton) writes them, the + * SDK exchange path reads them back through the same injected singleton. * * Exports OauthService for AdminController (admin OAuth-session panel) and the * MCP transport's token verification. @@ -56,7 +58,13 @@ import { Users } from '../../db/entities/Users.entity'; @Module({ imports: [RateLimitModule, AuditModule, AddonsModule, SchedulingModule, MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users])], controllers: [OauthPublicController, OauthApiController], - providers: [OauthService, TrekClientsStore, TrekOAuthProvider, OauthTokenRetentionJob], + providers: [ + OauthService, + TrekClientsStore, + TrekOAuthProvider, + OauthTokenRetentionJob, + { provide: PendingCodeStore, useValue: processPendingCodes }, + ], exports: [OauthService], }) export class OauthModule implements NestModule { diff --git a/server/src/nest/oauth/oauth.pending-codes.ts b/server/src/nest/oauth/oauth.pending-codes.ts index 19dff12b6..05c0add2c 100644 --- a/server/src/nest/oauth/oauth.pending-codes.ts +++ b/server/src/nest/oauth/oauth.pending-codes.ts @@ -1,9 +1,11 @@ /** * The short-lived authorization-code store. * - * Module-scoped + import-time interval on purpose (legacy parity — atlas-geo - * interval precedent). This is not a stylistic choice: the consent controller - * writes a code through the container OauthService and the SDK exchange path + * An injectable port, PendingCodeStore, with the in-memory implementation + * below. One instance is process-wide, with an import-time sweep on purpose + * (legacy parity, the atlas-geo interval precedent): OauthModule provides + * that instance and a hand-built OauthService defaults to it. Sharing is not + * a stylistic choice: the consent controller writes a code through the container OauthService and the SDK exchange path * (oauth-sdk.provider.ts) reads it back — through the same injected singleton * in production, but the integration harness and any second OauthService * instance must keep seeing the same map. Two maps would make the @@ -28,29 +30,62 @@ export interface PendingCode { export const MAX_PENDING_CODES = 500; export const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes -const pendingCodes = new Map(); +/** + * Where pending authorization codes are kept between the consent and the + * token exchange. In memory today, which ties an authorization to the process + * that issued it; a store shared between processes replaces the process + * instance in OauthModule without touching OauthService. + */ +export abstract class PendingCodeStore { + /** Keep `entry` under `code`; false when the store is at capacity (the caller answers with a null code). */ + abstract put(code: string, entry: PendingCode): boolean; + /** Single use: the entry is removed even when it turns out to be expired, and an expired one is null. */ + abstract take(code: string): PendingCode | null; + /** Drop everything past its TTL. */ + abstract sweep(now?: number): void; +} + +/** The current behaviour: one capped map in this process's memory. */ +export class InMemoryPendingCodeStore extends PendingCodeStore { + private readonly codes = new Map(); + + put(code: string, entry: PendingCode): boolean { + if (this.codes.size >= MAX_PENDING_CODES) return false; + this.codes.set(code, entry); + return true; + } + + take(code: string): PendingCode | null { + const entry = this.codes.get(code); + if (!entry) return null; + this.codes.delete(code); + if (Date.now() > entry.expiresAt) return null; + return entry; + } + + sweep(now = Date.now()): void { + for (const [key, entry] of this.codes) { + if (now > entry.expiresAt) this.codes.delete(key); + } + } +} + +/** The one store this process shares: the consent route writes it, the SDK exchange reads it. */ +export const processPendingCodes: PendingCodeStore = new InMemoryPendingCodeStore(); /** Drop everything past its TTL. Named so the interval body is reachable from a test. */ export function sweepPendingCodes(now = Date.now()): void { - for (const [key, entry] of pendingCodes) { - if (now > entry.expiresAt) pendingCodes.delete(key); - } + processPendingCodes.sweep(now); } setInterval(() => sweepPendingCodes(), 60_000).unref(); /** Returns false when the store is at capacity — the caller answers with a null code. */ export function putPendingCode(code: string, entry: PendingCode): boolean { - if (pendingCodes.size >= MAX_PENDING_CODES) return false; - pendingCodes.set(code, entry); - return true; + return processPendingCodes.put(code, entry); } /** Single-use: the entry is removed even when it turns out to be expired. */ export function takePendingCode(code: string): PendingCode | null { - const entry = pendingCodes.get(code); - if (!entry) return null; - pendingCodes.delete(code); - if (Date.now() > entry.expiresAt) return null; - return entry; + return processPendingCodes.take(code); } diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 190b01c9b..930bd3e10 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -35,7 +35,7 @@ import { redirectUriMatches, timingSafeEqualHex, } from './oauth.helpers'; -import { AUTH_CODE_TTL_MS, putPendingCode, takePendingCode, type PendingCode } from './oauth.pending-codes'; +import { AUTH_CODE_TTL_MS, PendingCodeStore, processPendingCodes, type PendingCode } from './oauth.pending-codes'; export type { PendingCode } from './oauth.pending-codes'; @@ -89,6 +89,7 @@ export class OauthService { @InjectRepository(OauthConsents) private readonly consents: OauthConsentsRepository, private readonly addons: AddonsService, private readonly audit: AuditService, + private readonly pendingCodes: PendingCodeStore = processPendingCodes, ) {} async mcpEnabled(): Promise { return this.addons.isAddonEnabled(ADDON_IDS.MCP); } @@ -240,12 +241,12 @@ export class OauthService { codeChallengeMethod: 'S256'; }): string | null { const rawCode = randomBytes(32).toString('hex'); - const stored = putPendingCode(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS }); + const stored = this.pendingCodes.put(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS }); return stored ? rawCode : null; } consumeAuthCode(code: string): PendingCode | null { - return takePendingCode(code); + return this.pendingCodes.take(code); } // ------------------------------------------------------------------------- diff --git a/server/src/nest/oidc/oidc-flow.store.ts b/server/src/nest/oidc/oidc-flow.store.ts new file mode 100644 index 000000000..b31874595 --- /dev/null +++ b/server/src/nest/oidc/oidc-flow.store.ts @@ -0,0 +1,85 @@ +/** A login that went to the provider and has not come back yet. */ +export interface OidcPendingState { + createdAt: number; + redirectUri: string; + inviteToken?: string; + codeVerifier: string; + remember?: boolean; +} + +/** + * A finished login waiting to be exchanged for the session. + * + * `bindingHash` is the sha256 of a secret that only the browser which finished + * the callback holds, in a cookie. The code itself travels in a URL, through + * history, referrers and any log in between, so on its own it is not a + * credential, and /exchange must not accept it as one. + */ +export interface OidcAuthCode { + token: string; + created: number; + remember?: boolean; + bindingHash: string; +} + +/** + * The short-lived OIDC flow state, as an injectable port: the `state` a login + * leaves with and the one-time code it comes back with. Both are single use. + * + * In memory today, which ties a login to the process that started it; with + * several processes behind one address the callback can land on another one. + * A store shared between processes replaces InMemoryOidcFlowStore in + * OidcModule without touching OidcService. + */ +export abstract class OidcFlowStore { + abstract putState(state: string, entry: OidcPendingState): void; + /** The entry, removed in the same step; null when unknown. */ + abstract takeState(state: string): OidcPendingState | null; + abstract putCode(code: string, entry: OidcAuthCode): void; + /** The entry, removed in the same step, expired or not (the caller decides); null when unknown. */ + abstract takeCode(code: string): OidcAuthCode | null; + /** Drop states created more than `ttlMs` before `now`. */ + abstract sweepStates(now: number, ttlMs: number): void; + /** Drop codes created more than `ttlMs` before `now`. */ + abstract sweepCodes(now: number, ttlMs: number): void; +} + +/** The current behaviour: two maps in this process's memory. */ +export class InMemoryOidcFlowStore extends OidcFlowStore { + private readonly states = new Map(); + private readonly codes = new Map(); + + putState(state: string, entry: OidcPendingState): void { + this.states.set(state, entry); + } + + takeState(state: string): OidcPendingState | null { + const entry = this.states.get(state); + if (!entry) return null; + this.states.delete(state); + return entry; + } + + putCode(code: string, entry: OidcAuthCode): void { + this.codes.set(code, entry); + } + + takeCode(code: string): OidcAuthCode | null { + const entry = this.codes.get(code); + if (!entry) return null; + this.codes.delete(code); + return entry; + } + + sweepStates(now: number, ttlMs: number): void { + for (const [state, entry] of this.states) { + if (now - entry.createdAt > ttlMs) this.states.delete(state); + } + } + + sweepCodes(now: number, ttlMs: number): void { + for (const [code, entry] of this.codes) { + if (now - entry.created > ttlMs) this.codes.delete(code); + } + } +} diff --git a/server/src/nest/oidc/oidc.module.ts b/server/src/nest/oidc/oidc.module.ts index ea77998f9..cb8a55df0 100644 --- a/server/src/nest/oidc/oidc.module.ts +++ b/server/src/nest/oidc/oidc.module.ts @@ -3,6 +3,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { AdminOidcController, OidcController } from './oidc.controller'; import { AuditModule } from '../audit/audit.module'; import { OidcService } from './oidc.service'; +import { InMemoryOidcFlowStore, OidcFlowStore } from './oidc-flow.store'; import { AuthModule } from '../auth/auth.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { Users } from '../../db/entities/Users.entity'; @@ -12,6 +13,8 @@ import { AppSettings } from '../../db/entities/AppSettings.entity'; @Module({ imports: [AuthModule, TripMembershipModule, AuditModule, MikroOrmModule.forFeature([Users, InviteTokens, AppSettings])], controllers: [OidcController, AdminOidcController], - providers: [OidcService], + // The login states and codes stay in this process's memory; a store shared + // between processes would be provided here instead. + providers: [OidcService, { provide: OidcFlowStore, useClass: InMemoryOidcFlowStore }], }) export class OidcModule {} diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index 138588700..618335bf8 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -21,6 +21,7 @@ import { InviteTokens } from '../../db/entities/InviteTokens.entity'; import type { InviteTokensRepository, InviteTokenRow } from '../../db/repositories/InviteTokens.repository'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import { InMemoryOidcFlowStore, OidcFlowStore } from './oidc-flow.store'; // --------------------------------------------------------------------------- // Types @@ -220,21 +221,9 @@ function toClientUser(row: UserRow): User { */ @Injectable() export class OidcService implements OnModuleDestroy { - // ------------------------------------------------------------------------- - // State management – pending OIDC states - // ------------------------------------------------------------------------- - - private readonly pendingStates = new Map(); - - // ------------------------------------------------------------------------- - // Auth code management – short-lived codes exchanged for JWT - // ------------------------------------------------------------------------- - - // `bindingHash` is the sha256 of a secret that only the browser which finished - // the callback holds, in a cookie. The code itself travels in a URL — through - // history, referrers and any log in between — so on its own it is not a - // credential, and /exchange must not accept it as one. - private readonly authCodes = new Map(); + // The pending login states and the one-time login codes live in the injected + // OidcFlowStore (oidc-flow.store.ts): in memory today, swappable for a store + // shared between processes. // Discovery document cache (1 h TTL), keyed by discovery URL so two // configured issuers no longer thrash a single slot. @@ -256,19 +245,10 @@ export class OidcService implements OnModuleDestroy { @InjectRepository(Users) private readonly usersRepo: UsersRepository, @InjectRepository(InviteTokens) private readonly inviteTokens: InviteTokensRepository, @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, + private readonly flows: OidcFlowStore = new InMemoryOidcFlowStore(), ) { - this.stateSweeper = setInterval(() => { - const now = Date.now(); - for (const [state, data] of this.pendingStates) { - if (now - data.createdAt > STATE_TTL) this.pendingStates.delete(state); - } - }, STATE_CLEANUP); - this.codeSweeper = setInterval(() => { - const now = Date.now(); - for (const [code, entry] of this.authCodes) { - if (now - entry.created > AUTH_CODE_TTL) this.authCodes.delete(code); - } - }, AUTH_CODE_CLEANUP); + this.stateSweeper = setInterval(() => this.flows.sweepStates(Date.now(), STATE_TTL), STATE_CLEANUP); + this.codeSweeper = setInterval(() => this.flows.sweepCodes(Date.now(), AUTH_CODE_TTL), AUTH_CODE_CLEANUP); } onModuleDestroy(): void { @@ -283,21 +263,18 @@ export class OidcService implements OnModuleDestroy { setAuthCookie(res: Response, token: string, req: Request, remember?: RememberOption) { setAuthCookie(res, token, req, remember); } // Creates the login state and a matching PKCE pair. The verifier stays server - // side (in pendingStates); the S256 challenge goes to the provider so PKCE- + // side (in the flow store); the S256 challenge goes to the provider so PKCE- // required setups (e.g. Pocket ID with PKCE = required) work. createState(redirectUri: string, inviteToken?: string, remember?: boolean): { state: string; codeChallenge: string } { const state = crypto.randomBytes(32).toString('hex'); const codeVerifier = base64url(crypto.randomBytes(32)); const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest()); - this.pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier, remember }); + this.flows.putState(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier, remember }); return { state, codeChallenge }; } consumeState(state: string) { - const pending = this.pendingStates.get(state); - if (!pending) return null; - this.pendingStates.delete(state); - return pending; + return this.flows.takeState(state); } /** @@ -310,17 +287,16 @@ export class OidcService implements OnModuleDestroy { createAuthCode(token: string, remember?: boolean): { code: string; binding: string } { const authCode: string = uuidv4(); const binding = crypto.randomBytes(32).toString('base64url'); - this.authCodes.set(authCode, { token, created: Date.now(), remember, bindingHash: sha256Hex(binding) }); + this.flows.putCode(authCode, { token, created: Date.now(), remember, bindingHash: sha256Hex(binding) }); return { code: authCode, binding }; } consumeAuthCode(code: string, binding?: string): { token: string; remember?: boolean } | { error: string } { - const entry = this.authCodes.get(code); - if (!entry) return { error: 'Invalid or expired code' }; // Single use, burnt on every outcome: a code seen by someone else must not // survive their attempt for a second guess, and the browser that owns it can // simply log in again. - this.authCodes.delete(code); + const entry = this.flows.takeCode(code); + if (!entry) return { error: 'Invalid or expired code' }; if (Date.now() - entry.created > AUTH_CODE_TTL) return { error: 'Code expired' }; // Same wording as the unknown-code case on purpose — whoever presents a code // without its binding learns nothing about whether the code was real. diff --git a/server/src/nest/permissions/permissions-cache.ts b/server/src/nest/permissions/permissions-cache.ts index 40e756b57..9d09f52bc 100644 --- a/server/src/nest/permissions/permissions-cache.ts +++ b/server/src/nest/permissions/permissions-cache.ts @@ -1,28 +1,60 @@ import type { PermissionLevel } from './permissions.service'; /** - * The permissions cache — module state on purpose, in its own home (the - * oauth.pending-codes.ts precedent) so every path that must see one shared - * cache actually does: the container PermissionsService singleton (the MCP - * _shared helper reaches it through injection, not a separate bridge - * instance — permissions.bridge no longer exists, see auth.service.ts:116), - * and the plain backup restore path (backup.impl.ts), which is free - * functions by design and has nothing to hang an injected service off. A - * restore rewrites app_settings under the request path's feet; invalidating - * here is what keeps every reader consistent afterwards. + * Where the loaded permission levels are kept between requests. + * + * An injectable port (PermissionsService takes it, PermissionsModule provides + * it) so a store shared between processes can replace the in-memory one + * without touching the service: today a permission change on one process is + * only seen by that process, which is right while TREK runs as one. */ -let cache: Map | null = null; +export abstract class PermissionsCacheStore { + /** The cached levels, or null when nothing is cached. */ + abstract get(): Map | null; + /** Install a completed read and return it. */ + abstract set(next: Map): Map; + /** Drop the cache, so the next reader loads the stored levels again. */ + abstract invalidate(): void; +} + +/** The current behaviour: one map in this process's memory. */ +export class InMemoryPermissionsCacheStore extends PermissionsCacheStore { + private cache: Map | null = null; + + get(): Map | null { + return this.cache; + } + + set(next: Map): Map { + this.cache = next; + return next; + } + + invalidate(): void { + this.cache = null; + } +} + +/** + * The one store this process shares, in its own home (the + * oauth.pending-codes.ts precedent) so every path that must see the same + * cache does: PermissionsModule provides this instance to the container's + * PermissionsService, a hand-built PermissionsService (the no-Nest test + * harnesses) defaults to it, and the plain backup restore path + * (backup.impl.ts), which is free functions by design, flushes it through + * invalidatePermissionsCache() below after a restore rewrites app_settings. + */ +export const processPermissionsCache: PermissionsCacheStore = new InMemoryPermissionsCacheStore(); export function getPermissionsCache(): Map | null { - return cache; + return processPermissionsCache.get(); } /** Install a fresh cache map and return it (the loader fills it in place). */ export function setPermissionsCache(next: Map): Map { - cache = next; - return next; + return processPermissionsCache.set(next); } export function invalidatePermissionsCache(): void { - cache = null; + processPermissionsCache.invalidate(); } diff --git a/server/src/nest/permissions/permissions.module.ts b/server/src/nest/permissions/permissions.module.ts index da771b20e..535dd369f 100644 --- a/server/src/nest/permissions/permissions.module.ts +++ b/server/src/nest/permissions/permissions.module.ts @@ -4,6 +4,7 @@ import { PermissionsService } from './permissions.service'; import { TripAccessGuard } from './trip-access.guard'; import { TripOwnerGuard } from './trip-owner.guard'; import { AppSettings } from '../../db/entities/AppSettings.entity'; +import { PermissionsCacheStore, processPermissionsCache } from './permissions-cache'; /** Cross-cutting permissions domain (Wave 2). No controller/MCP surface of its * own — the admin HTTP surface stays with AdminModule. Exports @@ -15,7 +16,14 @@ import { AppSettings } from '../../db/entities/AppSettings.entity'; * canAccessTrip (Plan 3c), unaffected by this. */ @Module({ imports: [MikroOrmModule.forFeature([AppSettings])], - providers: [PermissionsService, TripAccessGuard, TripOwnerGuard], + // The cache store is the process-wide in-memory one, the same instance the + // backup restore flushes; a shared store replaces it here. + providers: [ + PermissionsService, + TripAccessGuard, + TripOwnerGuard, + { provide: PermissionsCacheStore, useValue: processPermissionsCache }, + ], exports: [PermissionsService, TripAccessGuard, TripOwnerGuard], }) export class PermissionsModule {} diff --git a/server/src/nest/permissions/permissions.service.ts b/server/src/nest/permissions/permissions.service.ts index e870d28ff..943821c06 100644 --- a/server/src/nest/permissions/permissions.service.ts +++ b/server/src/nest/permissions/permissions.service.ts @@ -5,11 +5,7 @@ import { UnitOfWork } from '../database/unit-of-work'; import { logError } from '../audit/audit-log.logger'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; -import { - getPermissionsCache, - setPermissionsCache, - invalidatePermissionsCache as invalidateSharedCache, -} from './permissions-cache'; +import { PermissionsCacheStore, processPermissionsCache } from './permissions-cache'; /** * Permission levels (hierarchical, higher includes lower): @@ -67,24 +63,23 @@ export const PERMISSION_ACTIONS: PermissionAction[] = [ const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a])); -// The in-memory cache is deliberately MODULE-scoped, not instance state, and -// lives in ./permissions-cache: the container's PermissionsService singleton -// is not the only reader — the backup restore path (backup.impl.ts) is plain -// functions, no DI, and must flush the same cache the request path reads. -// Both reach the same shared connection, so a single cache is also the -// correct data shape. (permissions.bridge, once a second out-of-container -// instance, was replaced by injection — see auth.service.ts:116 — and no -// longer exists.) +// The cache is a PermissionsCacheStore (./permissions-cache), injected so a +// store shared between processes can be plugged in later. PermissionsModule +// provides the process-wide instance, which a hand-built service defaults to +// as well: the backup restore path (backup.impl.ts) is plain functions, no +// DI, and must flush the same cache the request path reads. + @Injectable() export class PermissionsService { constructor( @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, private readonly uow: UnitOfWork, + private readonly cacheStore: PermissionsCacheStore = processPermissionsCache, ) {} private async loadPermissions(): Promise> { - const cached = getPermissionsCache(); + const cached = this.cacheStore.get(); if (cached) return cached; const cache = new Map(); try { @@ -136,11 +131,11 @@ export class PermissionsService { return cache; } // Only a completed read becomes the shared cache. - return setPermissionsCache(cache); + return this.cacheStore.set(cache); } invalidatePermissionsCache(): void { - invalidateSharedCache(); + this.cacheStore.invalidate(); } async getPermissionLevel(actionKey: string): Promise { diff --git a/server/src/nest/realtime/realtime-gateway.module.ts b/server/src/nest/realtime/realtime-gateway.module.ts index 42367dbe2..97d4da9bf 100644 --- a/server/src/nest/realtime/realtime-gateway.module.ts +++ b/server/src/nest/realtime/realtime-gateway.module.ts @@ -6,6 +6,7 @@ import { JourneyDomainModule } from '../journey/journey-domain.module'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import { Users } from '../../db/entities/Users.entity'; import { Trips } from '../../db/entities/Trips.entity'; +import { processRooms, RoomRegistry } from './ws-state'; /** * The transport, kept out of RealtimeModule on purpose. @@ -29,6 +30,8 @@ import { Trips } from '../../db/entities/Trips.entity'; // Task 2 — handleJoin's own canAccessTrip delegate, now TripsRepository // directly. imports: [EphemeralTokenModule, JourneyDomainModule, MikroOrmModule.forFeature([Users, AppSettings, Trips])], - providers: [RealtimeGateway], + // The room registry is the process-wide in-memory one the broadcast + // functions read too; see RoomRegistry in ws-state.ts. + providers: [RealtimeGateway, { provide: RoomRegistry, useValue: processRooms }], }) export class RealtimeGatewayModule {} diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index f2047ae64..db4728f7f 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -23,13 +23,9 @@ import { logError } from '../audit/audit-log.logger'; import { bookPeers, broadcastToBook, - joinBook, - joinRoom, - leaveAllBooks, - leaveAllRooms, - leaveBook, - leaveRoom, + processRooms, registerSocket, + RoomRegistry, socketIdOf, userOf, type TrekWebSocket, @@ -48,10 +44,10 @@ const HEARTBEAT_INTERVAL = 30_000; * singleton and the token store, and the heartbeat is a lifecycle hook rather * than an interval nobody stops. * - * What did NOT move is the socket registry. It stays module-scoped in - * ws-state.ts because out-of-container code (the no-Nest test harnesses, - * the vi.mock'd src/websocket seam) must see the same rooms; see the note - * there. + * What did NOT move is the socket registry. It stays one process-wide + * instance in ws-state.ts (`processRooms`, injected here as the RoomRegistry + * port) because out-of-container code (the no-Nest test harnesses, the + * vi.mock'd src/websocket seam) must see the same rooms; see the note there. * * The wire protocol is unchanged, down to the frame names. TrekWsAdapter maps * `{ type }` onto @SubscribeMessage, because the stock adapter dispatches on @@ -78,6 +74,9 @@ export class RealtimeGateway private readonly journeys: JourneyDomainService, @InjectRepository(Users) private readonly users: UsersRepository, @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, + // Room membership, behind its port. The process-wide registry by default: + // the broadcast functions in ws-state.ts read the same instance. + private readonly rooms: RoomRegistry = processRooms, ) {} afterInit(server: WebSocketServer): void { @@ -166,10 +165,10 @@ export class RealtimeGateway } handleDisconnect(socket: TrekWebSocket): void { - leaveAllRooms(socket); + this.rooms.leaveAll(socket); // Tell the books this socket was in, or its pointer stays on everyone // else's page forever. - for (const journeyId of leaveAllBooks(socket)) this.announcePeers(journeyId); + for (const journeyId of this.rooms.leaveAllBooks(socket)) this.announcePeers(journeyId); } @SubscribeMessage('join') @@ -192,7 +191,7 @@ export class RealtimeGateway if (!Number.isFinite(tripId) || !(await this.trips.findAccessible(tripId, user.id))) { return { type: 'error', message: 'Access denied' }; } - joinRoom(socket, tripId); + this.rooms.join(socket, tripId); return { type: 'joined', tripId }; } @@ -217,7 +216,7 @@ export class RealtimeGateway return { type: 'error', message: 'Access denied' }; } - joinBook(socket, journeyId); + this.rooms.joinBook(socket, journeyId); this.announcePeers(journeyId); return { type: 'book:joined', journeyId }; } @@ -229,7 +228,7 @@ export class RealtimeGateway ): { type: string; journeyId?: number } | undefined { if (!message?.journeyId) return undefined; const journeyId = Number(message.journeyId); - leaveBook(socket, journeyId); + this.rooms.leaveBook(socket, journeyId); this.announcePeers(journeyId); return { type: 'book:left', journeyId }; } @@ -291,7 +290,7 @@ export class RealtimeGateway ): { type: string; tripId?: number; message?: string } | undefined { if (!message?.tripId) return undefined; const tripId = Number(message.tripId); - leaveRoom(socket, tripId); + this.rooms.leave(socket, tripId); return { type: 'left', tripId }; } } diff --git a/server/src/nest/realtime/ws-state.ts b/server/src/nest/realtime/ws-state.ts index cf3df1d76..ff934d89c 100644 --- a/server/src/nest/realtime/ws-state.ts +++ b/server/src/nest/realtime/ws-state.ts @@ -22,20 +22,106 @@ export interface TrekWebSocket extends WebSocket { isAlive: boolean; } -const rooms = new Map>(); - /** - * Who has a Studio book open, per journey. + * Who is in which room: the trip rooms, and the Studio book rooms. * - * Separate from the trip rooms above, and separate from "is a contributor - * online": a book carries pointers and a presence list, and both are only of - * interest to the people actually looking at it. Fanning them out to every + * A book carries pointers and a presence list, and both are only of interest + * to the people actually looking at it, so book rooms are separate from trip + * rooms and from "is a contributor online": fanning them out to every * contributor's sockets would send a pointer moving at ten frames a second to * someone reading the journey on their phone. + * + * An injectable port: RealtimeGatewayModule provides `processRooms` and the + * gateway takes it, while the broadcast functions below read the same + * instance. Membership holds live sockets, so it is local to the process by + * nature; running several processes needs a fan-out between them (each + * delivering to its own sockets) rather than a different place to keep these + * sets. */ -const bookRooms = new Map>(); -const socketBooks = new WeakMap>(); -const socketRooms = new WeakMap>(); +export abstract class RoomRegistry { + /** Start tracking a new connection's rooms. */ + abstract register(ws: TrekWebSocket): void; + abstract join(ws: TrekWebSocket, tripId: number): void; + abstract leave(ws: TrekWebSocket, tripId: number): void; + abstract leaveAll(ws: TrekWebSocket): void; + /** The sockets in a trip room; undefined when nobody is in it. */ + abstract members(tripId: number): ReadonlySet | undefined; + abstract joinBook(ws: TrekWebSocket, journeyId: number): void; + abstract leaveBook(ws: TrekWebSocket, journeyId: number): void; + /** Every book this socket had open, left in one go. */ + abstract leaveAllBooks(ws: TrekWebSocket): number[]; + /** The sockets looking at a book; undefined when nobody is. */ + abstract bookMembers(journeyId: number): ReadonlySet | undefined; +} + +/** The current behaviour: the rooms in this process's memory. */ +export class InMemoryRoomRegistry extends RoomRegistry { + private readonly rooms = new Map>(); + private readonly bookRooms = new Map>(); + private readonly socketBooks = new WeakMap>(); + private readonly socketRooms = new WeakMap>(); + + register(ws: TrekWebSocket): void { + this.socketRooms.set(ws, new Set()); + } + + join(ws: TrekWebSocket, tripId: number): void { + if (!this.rooms.has(tripId)) this.rooms.set(tripId, new Set()); + this.rooms.get(tripId)!.add(ws); + this.socketRooms.get(ws)?.add(tripId); + } + + leave(ws: TrekWebSocket, tripId: number): void { + const room = this.rooms.get(tripId); + if (room) { + room.delete(ws); + if (room.size === 0) this.rooms.delete(tripId); + } + this.socketRooms.get(ws)?.delete(tripId); + } + + leaveAll(ws: TrekWebSocket): void { + const mine = this.socketRooms.get(ws); + if (!mine) return; + for (const tripId of mine) this.leave(ws, tripId); + } + + members(tripId: number): ReadonlySet | undefined { + return this.rooms.get(tripId); + } + + joinBook(ws: TrekWebSocket, journeyId: number): void { + if (!this.bookRooms.has(journeyId)) this.bookRooms.set(journeyId, new Set()); + this.bookRooms.get(journeyId)!.add(ws); + if (!this.socketBooks.has(ws)) this.socketBooks.set(ws, new Set()); + this.socketBooks.get(ws)!.add(journeyId); + } + + leaveBook(ws: TrekWebSocket, journeyId: number): void { + const room = this.bookRooms.get(journeyId); + if (room) { + room.delete(ws); + if (room.size === 0) this.bookRooms.delete(journeyId); + } + this.socketBooks.get(ws)?.delete(journeyId); + } + + leaveAllBooks(ws: TrekWebSocket): number[] { + const mine = this.socketBooks.get(ws); + if (!mine) return []; + const left = [...mine]; + for (const journeyId of left) this.leaveBook(ws, journeyId); + return left; + } + + bookMembers(journeyId: number): ReadonlySet | undefined { + return this.bookRooms.get(journeyId); + } +} + +/** The one registry this process has; see RoomRegistry for who reads it. */ +export const processRooms: RoomRegistry = new InMemoryRoomRegistry(); + const socketUser = new WeakMap(); const socketId = new WeakMap(); @@ -64,7 +150,7 @@ export function registerSocket(ws: TrekWebSocket, user: User): number { const sid = nextSocketId++; socketId.set(ws, sid); socketUser.set(ws, user); - socketRooms.set(ws, new Set()); + processRooms.register(ws); return sid; } @@ -73,24 +159,15 @@ export function userOf(ws: TrekWebSocket): User | undefined { } export function joinRoom(ws: TrekWebSocket, tripId: number): void { - if (!rooms.has(tripId)) rooms.set(tripId, new Set()); - rooms.get(tripId)!.add(ws); - socketRooms.get(ws)?.add(tripId); + processRooms.join(ws, tripId); } export function leaveRoom(ws: TrekWebSocket, tripId: number): void { - const room = rooms.get(tripId); - if (room) { - room.delete(ws); - if (room.size === 0) rooms.delete(tripId); - } - socketRooms.get(ws)?.delete(tripId); + processRooms.leave(ws, tripId); } export function leaveAllRooms(ws: TrekWebSocket): void { - const mine = socketRooms.get(ws); - if (!mine) return; - for (const tripId of mine) leaveRoom(ws, tripId); + processRooms.leaveAll(ws); } // ── Studio books ────────────────────────────────────────────────────────── @@ -103,28 +180,16 @@ export interface BookPeer { } export function joinBook(ws: TrekWebSocket, journeyId: number): void { - if (!bookRooms.has(journeyId)) bookRooms.set(journeyId, new Set()); - bookRooms.get(journeyId)!.add(ws); - if (!socketBooks.has(ws)) socketBooks.set(ws, new Set()); - socketBooks.get(ws)!.add(journeyId); + processRooms.joinBook(ws, journeyId); } export function leaveBook(ws: TrekWebSocket, journeyId: number): void { - const room = bookRooms.get(journeyId); - if (room) { - room.delete(ws); - if (room.size === 0) bookRooms.delete(journeyId); - } - socketBooks.get(ws)?.delete(journeyId); + processRooms.leaveBook(ws, journeyId); } /** Every book this socket had open — called when the connection goes. */ export function leaveAllBooks(ws: TrekWebSocket): number[] { - const mine = socketBooks.get(ws); - if (!mine) return []; - const left = [...mine]; - for (const journeyId of left) leaveBook(ws, journeyId); - return left; + return processRooms.leaveAllBooks(ws); } /** @@ -134,7 +199,7 @@ export function leaveAllBooks(ws: TrekWebSocket): number[] { * pointers, and a list keyed by user could not say which one moved. */ export function bookPeers(journeyId: number): BookPeer[] { - const room = bookRooms.get(journeyId); + const room = processRooms.bookMembers(journeyId); if (!room) return []; const peers: BookPeer[] = []; for (const ws of room) { @@ -159,7 +224,7 @@ export function broadcastToBook( payload: Record, excludeSid?: number, ): void { - const room = bookRooms.get(journeyId); + const room = processRooms.bookMembers(journeyId); if (!room || room.size === 0) return; for (const ws of room) { if (ws.readyState !== 1) continue; @@ -194,7 +259,7 @@ export function broadcast( // and with no ws server at all, and skipping plugin:* re-broadcasts so a // plugin's own events can't loop back. if (!eventType.startsWith('plugin:')) emitPluginEvent(tripId, eventType, pluginEventMeta(eventType, payload)); - const room = rooms.get(tripId); + const room = processRooms.members(tripId); if (!room || room.size === 0) return; const excludeNum = excludeSid ? Number(excludeSid) : null; diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 9b4b4e574..50ef6d503 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -60,7 +60,12 @@ import { AuditService } from '../../../src/nest/audit/audit.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { getMcpSafeUrl } from '../../../src/app-config'; import { ADDON_IDS } from '../../../src/addons'; -import { MAX_PENDING_CODES, sweepPendingCodes } from '../../../src/nest/oauth/oauth.pending-codes'; +import { + MAX_PENDING_CODES, + PendingCodeStore, + processPendingCodes, + sweepPendingCodes, +} from '../../../src/nest/oauth/oauth.pending-codes'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository'; @@ -1323,7 +1328,13 @@ describe('OauthModule', () => { expect(controllers).toEqual([OauthPublicController, OauthApiController]); // RateLimitService is deliberately absent: it comes from the global // RateLimitModule so all consumers share one set of counters. - expect(providers).toEqual([Svc, TrekClientsStore, TrekOAuthProvider, OauthTokenRetentionJob]); + expect(providers).toEqual([ + Svc, + TrekClientsStore, + TrekOAuthProvider, + OauthTokenRetentionJob, + { provide: PendingCodeStore, useValue: processPendingCodes }, + ]); }); }); diff --git a/server/tests/unit/nest/process-state-ports.test.ts b/server/tests/unit/nest/process-state-ports.test.ts new file mode 100644 index 000000000..9bfbfcd66 --- /dev/null +++ b/server/tests/unit/nest/process-state-ports.test.ts @@ -0,0 +1,204 @@ +/** + * The process-local state behind injectable ports: the rate-limit counters, + * the OAuth pending codes, the OIDC login states and codes, the permissions + * cache and the WebSocket rooms. Each has an in-memory implementation that + * behaves exactly as the state did before, is provided by its module, and is + * what a hand-built consumer falls back to; a shared store can replace it in + * the module. PORTS-001 through PORTS-014. + */ +import { describe, it, expect, vi } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { RateLimitModule } from '../../../src/nest/common/rate-limit.module'; +import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import { InMemoryRateLimitStore, RateLimitStore } from '../../../src/nest/common/rate-limit.store'; +import { + InMemoryPendingCodeStore, + MAX_PENDING_CODES, + PendingCodeStore, + processPendingCodes, + type PendingCode, +} from '../../../src/nest/oauth/oauth.pending-codes'; +import { OauthService } from '../../../src/nest/oauth/oauth.service'; +import { InMemoryOidcFlowStore, OidcFlowStore } from '../../../src/nest/oidc/oidc-flow.store'; +import { OidcModule } from '../../../src/nest/oidc/oidc.module'; +import { + InMemoryPermissionsCacheStore, + PermissionsCacheStore, + invalidatePermissionsCache, + processPermissionsCache, +} from '../../../src/nest/permissions/permissions-cache'; +import { PermissionsModule } from '../../../src/nest/permissions/permissions.module'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { InMemoryRoomRegistry, RoomRegistry, processRooms, type TrekWebSocket } from '../../../src/nest/realtime/ws-state'; +import { RealtimeGatewayModule } from '../../../src/nest/realtime/realtime-gateway.module'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; + +const WINDOW = 60_000; + +function code(over: Partial = {}): PendingCode { + return { + clientId: 'c', + userId: 1, + redirectUri: 'https://app.example/cb', + scopes: ['trips:read'], + resource: null, + codeChallenge: 'x', + codeChallengeMethod: 'S256', + expiresAt: Date.now() + 60_000, + ...over, + }; +} + +describe('rate limits', () => { + it('PORTS-001: RateLimitService counts through the store it is given', () => { + const store = { hit: vi.fn(() => false), reset: vi.fn() } as unknown as RateLimitStore; + const service = new RateLimitService(store); + expect(service.check('login', '1.2.3.4', 5, WINDOW, 1_000)).toBe(false); + expect(store.hit).toHaveBeenCalledWith('login', '1.2.3.4', 5, WINDOW, 1_000); + service.reset('login'); + expect(store.reset).toHaveBeenCalledWith('login'); + }); + + it('PORTS-002: RateLimitModule provides the in-memory store, and the service gets it', async () => { + const moduleRef = await Test.createTestingModule({ imports: [RateLimitModule] }).compile(); + try { + const service = moduleRef.get(RateLimitService); + expect((service as unknown as { store: unknown }).store).toBeInstanceOf(InMemoryRateLimitStore); + for (let i = 0; i < 3; i++) expect(service.check('mfa', 'k', 3, WINDOW, 1_000)).toBe(true); + expect(service.check('mfa', 'k', 3, WINDOW, 1_000)).toBe(false); + } finally { + await moduleRef.close(); + } + }); + + it('PORTS-003: two hand-built services keep their own counters, as before', () => { + const a = new RateLimitService(); + const b = new RateLimitService(); + a.check('login', 'k', 1, WINDOW, 1_000); + expect(a.check('login', 'k', 1, WINDOW, 1_000)).toBe(false); + expect(b.check('login', 'k', 1, WINDOW, 1_000)).toBe(true); + }); +}); + +describe('OAuth pending codes', () => { + it('PORTS-004: a code is single use, and an expired one is burnt and refused', () => { + const store = new InMemoryPendingCodeStore(); + expect(store.put('a', code())).toBe(true); + expect(store.take('a')?.clientId).toBe('c'); + expect(store.take('a')).toBeNull(); + store.put('old', code({ expiresAt: Date.now() - 1 })); + expect(store.take('old')).toBeNull(); + }); + + it('PORTS-005: the store refuses past its capacity and the sweep frees expired entries', () => { + const store = new InMemoryPendingCodeStore(); + for (let i = 0; i < MAX_PENDING_CODES; i++) store.put(`c${i}`, code({ expiresAt: 10 })); + expect(store.put('one-more', code())).toBe(false); + store.sweep(11); + expect(store.put('one-more', code())).toBe(true); + }); + + it('PORTS-006: OauthService issues and redeems codes through the injected store', () => { + const store = new InMemoryPendingCodeStore(); + const service = new OauthService({} as never, {} as never, {} as never, {} as never, {} as never, store); + const issued = service.createAuthCode({ + clientId: 'c', + userId: 1, + redirectUri: 'https://app.example/cb', + scopes: [], + resource: null, + codeChallenge: 'x', + codeChallengeMethod: 'S256', + }); + expect(issued).toMatch(/^[0-9a-f]{64}$/); + expect(processPendingCodes.take(issued!)).toBeNull(); + expect(service.consumeAuthCode(issued!)?.clientId).toBe('c'); + }); + + it('PORTS-007: OauthModule provides the process-wide store, the one a hand-built service defaults to', async () => { + const { OauthModule } = await import('../../../src/nest/oauth/oauth.module'); + expectRegisteredProvider(OauthModule, { provide: PendingCodeStore, useValue: processPendingCodes }); + const service = new OauthService({} as never, {} as never, {} as never, {} as never, {} as never); + expect((service as unknown as { pendingCodes: unknown }).pendingCodes).toBe(processPendingCodes); + }); +}); + +describe('OIDC login states and codes', () => { + it('PORTS-008: states and codes are single use', () => { + const store = new InMemoryOidcFlowStore(); + store.putState('s', { createdAt: 1, redirectUri: '/', codeVerifier: 'v' }); + expect(store.takeState('s')?.codeVerifier).toBe('v'); + expect(store.takeState('s')).toBeNull(); + store.putCode('c', { token: 't', created: 1, bindingHash: 'h' }); + expect(store.takeCode('c')?.token).toBe('t'); + expect(store.takeCode('c')).toBeNull(); + }); + + it('PORTS-009: the sweeps drop only what is past its TTL', () => { + const store = new InMemoryOidcFlowStore(); + store.putState('old', { createdAt: 0, redirectUri: '/', codeVerifier: 'v' }); + store.putState('new', { createdAt: 900, redirectUri: '/', codeVerifier: 'v' }); + store.putCode('old', { token: 't', created: 0, bindingHash: 'h' }); + store.putCode('new', { token: 't', created: 900, bindingHash: 'h' }); + store.sweepStates(1_000, 500); + store.sweepCodes(1_000, 500); + expect(store.takeState('old')).toBeNull(); + expect(store.takeState('new')).not.toBeNull(); + expect(store.takeCode('old')).toBeNull(); + expect(store.takeCode('new')).not.toBeNull(); + }); + + it('PORTS-010: OidcModule provides the in-memory store', () => { + expectRegisteredProvider(OidcModule, { provide: OidcFlowStore, useClass: InMemoryOidcFlowStore }); + }); +}); + +describe('the permissions cache', () => { + it('PORTS-011: PermissionsService reads, fills and drops the cache through the store it is given', async () => { + const store = new InMemoryPermissionsCacheStore(); + const appSettings = { findByKeyPrefix: vi.fn(async () => [{ key: 'perm_trip_create', value: 'admin' }]) }; + const service = new PermissionsService(appSettings as never, {} as never, store); + expect(await service.getPermissionLevel('trip_create')).toBe('admin'); + expect(store.get()?.get('trip_create')).toBe('admin'); + await service.getPermissionLevel('trip_create'); + expect(appSettings.findByKeyPrefix).toHaveBeenCalledTimes(1); + service.invalidatePermissionsCache(); + expect(store.get()).toBeNull(); + }); + + it('PORTS-012: the module, a hand-built service and the restore path share the process-wide store', () => { + expectRegisteredProvider(PermissionsModule, { provide: PermissionsCacheStore, useValue: processPermissionsCache }); + const service = new PermissionsService({} as never, {} as never); + expect((service as unknown as { cacheStore: unknown }).cacheStore).toBe(processPermissionsCache); + processPermissionsCache.set(new Map([['trip_create', 'admin']])); + invalidatePermissionsCache(); + expect(processPermissionsCache.get()).toBeNull(); + }); +}); + +describe('the WebSocket rooms', () => { + const socket = () => ({}) as TrekWebSocket; + + it('PORTS-013: joins, leaves and per-socket bookkeeping behave as the module maps did', () => { + const rooms = new InMemoryRoomRegistry(); + const a = socket(); + const b = socket(); + rooms.register(a); + rooms.register(b); + rooms.join(a, 1); + rooms.join(b, 1); + rooms.join(a, 2); + expect([...rooms.members(1)!]).toEqual([a, b]); + rooms.leaveAll(a); + expect([...rooms.members(1)!]).toEqual([b]); + expect(rooms.members(2)).toBeUndefined(); + rooms.joinBook(a, 7); + rooms.joinBook(a, 8); + expect(rooms.leaveAllBooks(a)).toEqual([7, 8]); + expect(rooms.bookMembers(7)).toBeUndefined(); + }); + + it('PORTS-014: RealtimeGatewayModule provides the process-wide registry the broadcasts read', () => { + expectRegisteredProvider(RealtimeGatewayModule, { provide: RoomRegistry, useValue: processRooms }); + }); +}); diff --git a/server/tests/unit/nest/rate-limit.service.test.ts b/server/tests/unit/nest/rate-limit.service.test.ts index b0c9d7e73..8c75d9415 100644 --- a/server/tests/unit/nest/rate-limit.service.test.ts +++ b/server/tests/unit/nest/rate-limit.service.test.ts @@ -6,13 +6,14 @@ */ import { describe, it, expect } from 'vitest'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import { InMemoryRateLimitStore } from '../../../src/nest/common/rate-limit.store'; const WINDOW = 15 * 60 * 1000; -/** The private bucket map, which is the only place the housekeeping is visible. */ +/** The in-memory store's bucket size, which is the only place the housekeeping is visible. */ function bucketSize(service: RateLimitService, bucket: string): number { - const buckets = (service as unknown as { buckets: Map> }).buckets; - return buckets.get(bucket)?.size ?? 0; + const store = (service as unknown as { store: InMemoryRateLimitStore }).store; + return store.size(bucket); } describe('RateLimitService', () => {