mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
refactor(server): put process-local state behind injectable stores
Rate-limit counters, OAuth pending codes, OIDC login states and codes, the permissions cache and the WebSocket rooms were Maps on module or instance scope. That is right while TREK runs as one process, but nothing could be swapped without editing every consumer. Each now sits behind a port with an in-memory implementation that keeps the old behaviour exactly: RateLimitStore, PendingCodeStore, OidcFlowStore, PermissionsCacheStore and RoomRegistry. The owning module provides the implementation with the same lifetime the state had (process-wide for the pending codes, the permissions cache and the rooms, per instance for the rate limits and the OIDC flows), the consumers take it through their constructor, and a hand-built consumer falls back to the same in-memory store, so tests and the free-function paths (the backup restore's cache flush, the broadcasts) see what they saw before. A store shared between processes can replace a provider later without touching the services.
This commit is contained in:
@@ -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 <id>]`. 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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, Map<string, Attempt>>();
|
||||
/** Last sweep per bucket, so a busy bucket doesn't keep a quiet one from being cleaned. */
|
||||
private readonly lastSweep = new Map<string, number>();
|
||||
|
||||
private store(bucket: string): Map<string, Attempt> {
|
||||
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<string, Attempt>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Map<string, Attempt>>();
|
||||
/** Last sweep per bucket, so a busy bucket doesn't keep a quiet one from being cleaned. */
|
||||
private readonly lastSweep = new Map<string, number>();
|
||||
|
||||
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<string, Attempt> {
|
||||
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<string, Attempt>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, PendingCode>();
|
||||
/**
|
||||
* 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<string, PendingCode>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<boolean> { 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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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<string, OidcPendingState>();
|
||||
private readonly codes = new Map<string, OidcAuthCode>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string; remember?: boolean }>();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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<string, { token: string; created: number; remember?: boolean; bindingHash: string }>();
|
||||
// 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.
|
||||
|
||||
@@ -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<string, PermissionLevel> | null = null;
|
||||
export abstract class PermissionsCacheStore {
|
||||
/** The cached levels, or null when nothing is cached. */
|
||||
abstract get(): Map<string, PermissionLevel> | null;
|
||||
/** Install a completed read and return it. */
|
||||
abstract set(next: Map<string, PermissionLevel>): Map<string, PermissionLevel>;
|
||||
/** 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<string, PermissionLevel> | null = null;
|
||||
|
||||
get(): Map<string, PermissionLevel> | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
set(next: Map<string, PermissionLevel>): Map<string, PermissionLevel> {
|
||||
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<string, PermissionLevel> | 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<string, PermissionLevel>): Map<string, PermissionLevel> {
|
||||
cache = next;
|
||||
return next;
|
||||
return processPermissionsCache.set(next);
|
||||
}
|
||||
|
||||
export function invalidatePermissionsCache(): void {
|
||||
cache = null;
|
||||
processPermissionsCache.invalidate();
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<Map<string, PermissionLevel>> {
|
||||
const cached = getPermissionsCache();
|
||||
const cached = this.cacheStore.get();
|
||||
if (cached) return cached;
|
||||
const cache = new Map<string, PermissionLevel>();
|
||||
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<PermissionLevel> {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,20 +22,106 @@ export interface TrekWebSocket extends WebSocket {
|
||||
isAlive: boolean;
|
||||
}
|
||||
|
||||
const rooms = new Map<number, Set<TrekWebSocket>>();
|
||||
|
||||
/**
|
||||
* 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<number, Set<TrekWebSocket>>();
|
||||
const socketBooks = new WeakMap<TrekWebSocket, Set<number>>();
|
||||
const socketRooms = new WeakMap<TrekWebSocket, Set<number>>();
|
||||
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<TrekWebSocket> | 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<TrekWebSocket> | undefined;
|
||||
}
|
||||
|
||||
/** The current behaviour: the rooms in this process's memory. */
|
||||
export class InMemoryRoomRegistry extends RoomRegistry {
|
||||
private readonly rooms = new Map<number, Set<TrekWebSocket>>();
|
||||
private readonly bookRooms = new Map<number, Set<TrekWebSocket>>();
|
||||
private readonly socketBooks = new WeakMap<TrekWebSocket, Set<number>>();
|
||||
private readonly socketRooms = new WeakMap<TrekWebSocket, Set<number>>();
|
||||
|
||||
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<TrekWebSocket> | 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<TrekWebSocket> | 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<TrekWebSocket, User>();
|
||||
const socketId = new WeakMap<TrekWebSocket, number>();
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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;
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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> = {}): 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, Map<string, unknown>> }).buckets;
|
||||
return buckets.get(bucket)?.size ?? 0;
|
||||
const store = (service as unknown as { store: InMemoryRateLimitStore }).store;
|
||||
return store.size(bucket);
|
||||
}
|
||||
|
||||
describe('RateLimitService', () => {
|
||||
|
||||
Reference in New Issue
Block a user