refactor(server): make the state ports async and route every reader through the provided store

The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
This commit is contained in:
Maurice
2026-10-08 16:51:23 +02:00
parent fa0e6a3eae
commit b3a085df3f
100 changed files with 956 additions and 632 deletions
@@ -47,7 +47,7 @@ export class PublicStatsController {
@Get('stats')
async stats(@Req() req: Request): Promise<PublicApiStats> {
enforcePublicApiRateLimit(this.rl, req);
await enforcePublicApiRateLimit(this.rl, req);
requireScope(req, 'stats');
const userId = requireUserId(req);
@@ -29,8 +29,8 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class AuthPublicController {
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
private async limit(bucket: string, req: Request, max: number): Promise<void> {
if (!(await this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
}
}
@@ -57,7 +57,7 @@ export class AuthPublicController {
@Get('invite/:token')
@Public('the invite token IS the credential')
async invite(@Param('token') token: string, @Req() req: Request) {
this.limit('login', req, 10);
await this.limit('login', req, 10);
const result = await this.auth.validateInviteToken(token);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -69,7 +69,7 @@ export class AuthPublicController {
@Public('creating the account that would carry the session')
@HttpCode(201)
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
await this.limit('login', req, 10);
const result = await this.auth.registerUser(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -83,7 +83,7 @@ export class AuthPublicController {
@Public('the login itself')
@HttpCode(200)
async login(@Body() body: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
await this.limit('login', req, 10);
const started = Date.now();
const result = await this.auth.loginUser(body);
if (result.auditAction) {
@@ -111,7 +111,7 @@ export class AuthPublicController {
@Public('reached by somebody who cannot log in')
@HttpCode(200)
async forgotPassword(@Body() body: ForgotPasswordDto, @Req() req: Request) {
this.limit('forgot', req, 3);
await this.limit('forgot', req, 3);
const started = Date.now();
const rawEmail = typeof body?.email === 'string' ? body.email : '';
const ip = getClientIp(req);
@@ -141,7 +141,7 @@ export class AuthPublicController {
async resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) {
// Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
this.limit('reset', req, 5);
await this.limit('reset', req, 5);
const ip = getClientIp(req);
const result = await this.auth.resetPassword(body);
if (result.error) {
@@ -159,7 +159,7 @@ export class AuthPublicController {
@Public('second factor of a login that has no session yet')
@HttpCode(200)
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('mfa', req, 5);
await this.limit('mfa', req, 5);
const result = await this.auth.verifyMfaLogin(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
+12 -12
View File
@@ -75,8 +75,8 @@ export const AVATAR_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => {
export class AuthController {
constructor(private readonly auth: AuthService, private readonly profile: UserProfileService, private readonly tokens: TokenService, private readonly rl: RateLimitService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, private readonly storage: StorageService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
private async limit(bucket: string, req: Request, max: number): Promise<void> {
if (!(await this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
}
}
@@ -90,8 +90,8 @@ export class AuthController {
* (login, register, forgot-password) keep the IP key — there is no account
* to charge yet.
*/
private limitUser(bucket: string, userId: number, max: number): void {
if (!this.rl.check(bucket, String(userId), max, WINDOW, Date.now())) {
private async limitUser(bucket: string, userId: number, max: number): Promise<void> {
if (!(await this.rl.check(bucket, String(userId), max, WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
}
}
@@ -108,7 +108,7 @@ export class AuthController {
@Put('me/password')
async changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 5);
await this.limit('login', req, 5);
// Carry the session's remember choice into the re-issued token/cookie so a
// "remember me" login survives a password change (#1927). Bearer callers
// have no cookie → undefined → the historical default duration.
@@ -275,7 +275,7 @@ export class AuthController {
@MfaExempt('completing setup is the way out of the policy')
@HttpCode(200)
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
this.limit('mfa', req, 5);
await this.limit('mfa', req, 5);
const result = await this.auth.enableMfa(user.id, body.code);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -287,7 +287,7 @@ export class AuthController {
@Post('mfa/disable')
@HttpCode(200)
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
this.limit('login', req, 5);
await this.limit('login', req, 5);
const result = await this.auth.disableMfa(user.id, user.email, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -305,7 +305,7 @@ export class AuthController {
@Post('mcp-tokens')
@HttpCode(201)
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
await this.limit('login', req, 5);
const result = await this.tokens.createMcpToken(user.id, body.name);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -342,7 +342,7 @@ export class AuthController {
@Post('api-tokens')
@HttpCode(201)
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
await this.limit('login', req, 5);
// No `scopes` means the key reads everything, which is what every key minted
// before this field existed does. Narrowing stays opt-in so the change
// cannot break an integration that is already running.
@@ -370,7 +370,7 @@ export class AuthController {
// above any real client, which mints one token per socket connect, but it
// stops a single account from filling the process-wide ephemeral store and
// 503-ing every other user's ws and download tokens.
this.limitUser('ws_token', user.id, 120);
await this.limitUser('ws_token', user.id, 120);
const result = await this.tokens.createWsToken(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
@@ -380,8 +380,8 @@ export class AuthController {
@Post('resource-token')
@HttpCode(200)
resourceToken(@CurrentUser() user: User, @Body() body: ResourceTokenDto) {
this.limitUser('resource_token', user.id, 120);
async resourceToken(@CurrentUser() user: User, @Body() body: ResourceTokenDto) {
await this.limitUser('resource_token', user.id, 120);
const token = this.tokens.createResourceToken(user.id, body.purpose);
if (!token) {
throw new HttpException({ error: 'Service unavailable' }, 503);
+6 -6
View File
@@ -36,8 +36,8 @@ export class PasskeyController {
private readonly passkeys: PasskeyService,
) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) {
private async limit(bucket: string, req: Request, max: number): Promise<void> {
if (!(await this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
}
}
@@ -48,7 +48,7 @@ export class PasskeyController {
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
this.limit('mfa', req, 5);
await this.limit('mfa', req, 5);
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
// — the RP ID itself is never derived from request headers.
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
@@ -73,7 +73,7 @@ export class PasskeyController {
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard)
async loginOptions(@Req() req: Request) {
this.limit('login', req, 10);
await this.limit('login', req, 10);
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
@@ -84,7 +84,7 @@ export class PasskeyController {
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard)
async loginVerify(@Body() body: PasskeyLoginVerifyDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
await this.limit('login', req, 10);
const started = Date.now();
const result = await this.passkeys.passkeyLoginVerify(body);
if (result.auditAction) {
@@ -119,7 +119,7 @@ export class PasskeyController {
@Delete('credentials/:id')
@UseGuards(JwtAuthGuard)
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
this.limit('login', req, 5);
await this.limit('login', req, 5);
const result = await this.passkeys.deletePasskey(user.id, id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
+8 -4
View File
@@ -447,9 +447,13 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
// still holds the pre-restore state. Any request using a cached
// permission would decide against the wrong grants until the
// next restart. Dropping the cache forces a fresh read.
// D6: no repository read happens on this path today (invalidation is a
// plain function, not a DB call), so no withRequestContext is owed here.
invalidatePermissionsCache();
// D6: no repository read happens on this path today. The flush goes to
// the store PermissionsModule installed (permissionsCacheSlot), and the
// in-memory one makes no DB call, so no withRequestContext is owed here
// yet. A store that reads the database, or the domain phase that gives
// this restore path a repository read (Plan 3's admin/backup cluster),
// must wrap it then; see task-2-review.md's non-HTTP caller table.
await invalidatePermissionsCache();
}
if (!reinitFailed) {
@@ -517,7 +521,7 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
// we leave the process in after a failed restore.
// D6: same no-repository-read note as the other invalidatePermissionsCache()
// call above — nothing to wrap yet.
try { invalidatePermissionsCache(); } catch { /* best-effort */ }
try { await invalidatePermissionsCache(); } catch { /* best-effort */ }
throw err;
}
}
+23 -9
View File
@@ -646,18 +646,20 @@ export class CollabService {
// entry yet, since the first has not answered. Joining the running fetch keeps
// that a single outbound request instead of twenty.
const running = this.inFlight.get(url);
if (running !== undefined) return { ...(await running), url };
// Charged per outbound fetch rather than per request, which is what the
// budget is actually protecting. Without a user there is no one to charge —
// no caller passes that today, and the fetch stays behind the SSRF guard.
if (userId !== undefined && !this.rateLimit.check('collab_link_preview', String(userId), PREVIEW_FETCHES_PER_MINUTE, 60_000, Date.now())) {
return { ...fallback, rateLimited: true };
if (running !== undefined) {
const shared = await running;
// The asker this call joined was out of budget, which says nothing about
// this caller's: ask again, first in line or behind the next asker.
if (shared.rateLimited) return this.linkPreview(url, userId);
return { ...shared, url };
}
// Memoised in-flight fetch, not a missing await: the promise is stored so the
// concurrent askers above can join it, and this frame awaits it below.
const task = this.fetchPreview(url, fallback);
// concurrent askers above can join it, and this frame awaits it below. It is
// stored before the budget check, which awaits its store, so an asker that
// arrives meanwhile joins this one instead of being charged for a fetch it
// does not make.
const task = this.chargeAndFetch(url, fallback, userId);
this.inFlight.set(url, task);
try {
return await task;
@@ -666,6 +668,18 @@ export class CollabService {
}
}
/**
* Charged per outbound fetch rather than per request, which is what the
* budget is actually protecting. Without a user there is no one to charge:
* no caller passes that today, and the fetch stays behind the SSRF guard.
*/
private async chargeAndFetch(url: string, fallback: LinkPreviewResult, userId?: number): Promise<LinkPreviewResult> {
if (userId !== undefined && !(await this.rateLimit.check('collab_link_preview', String(userId), PREVIEW_FETCHES_PER_MINUTE, 60_000, Date.now()))) {
return { ...fallback, rateLimited: true };
}
return this.fetchPreview(url, fallback);
}
/** The outbound half of linkPreview, past the cache and the budget. */
private async fetchPreview(url: string, fallback: LinkPreviewResult): Promise<LinkPreviewResult> {
const ssrf = await checkSsrf(url, true);
@@ -0,0 +1,41 @@
/**
* The store a piece of process-wide state is kept in right now.
*
* A store port (the permissions cache, the OAuth pending codes, the socket
* rooms) is provided by its module, so a store shared between processes can
* be swapped in there. Some readers cannot take it through a constructor: the
* backup restore is plain functions, the broadcasts are free functions every
* domain calls, and the no-Nest test harnesses build consumers by hand. They
* read the slot instead, and the owning module installs whatever the
* container resolved for the port, so swapping the provider changes every
* reader, not only the injected ones.
*
* Starts on the in-memory default, which is also what the module provides
* unless it is overridden, so a process that never builds the module (a
* hand-built test) sees what it saw before.
*/
export class ProcessStoreSlot<T> {
private current: T;
constructor(private readonly fallback: T) {
this.current = fallback;
}
/** The store every out-of-container reader uses now. */
get(): T {
return this.current;
}
/** Called by the owning module with the store the container resolved. */
install(store: T): void {
this.current = store;
}
/**
* Called when that module is torn down: back to the default, unless a later
* app has installed its own store since.
*/
release(store: T): void {
if (this.current === store) this.current = this.fallback;
}
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InMemoryRateLimitStore, RateLimitStore } from './rate-limit.store';
/**
* Per-IP rate limiting for the auth-adjacent routes. `check` returns false
* Per-IP rate limiting for the auth-adjacent routes. `check` resolves false
* once a key exceeds `max` within `windowMs` (the caller answers 429).
*
* The counting lives in an injected RateLimitStore. RateLimitModule provides
@@ -13,13 +13,13 @@ import { InMemoryRateLimitStore, RateLimitStore } from './rate-limit.store';
export class RateLimitService {
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 {
/** Resolves true when the request is allowed, false when it should be rejected (429). */
check(bucket: string, key: string, max: number, windowMs: number, now: number): Promise<boolean> {
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 {
this.store.reset(bucket);
reset(bucket?: string): Promise<void> {
return this.store.reset(bucket);
}
}
+24 -17
View File
@@ -5,7 +5,8 @@
* 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.
* service or its callers. Both methods return a promise, because such a
* store lives in the database or on the network.
*/
export abstract class RateLimitStore {
/**
@@ -13,9 +14,9 @@ export abstract class RateLimitStore {
* 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;
abstract hit(bucket: string, key: string, max: number, windowMs: number, now: number): Promise<boolean>;
/** Clear one bucket, or every bucket. */
abstract reset(bucket?: string): void;
abstract reset(bucket?: string): Promise<void>;
}
interface Attempt {
@@ -40,7 +41,26 @@ export class InMemoryRateLimitStore extends RateLimitStore {
/** 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 {
hit(bucket: string, key: string, max: number, windowMs: number, now: number): Promise<boolean> {
return Promise.resolve(this.count(bucket, key, max, windowMs, now));
}
reset(bucket?: string): Promise<void> {
if (bucket) this.buckets.get(bucket)?.clear();
else {
this.buckets.clear();
this.lastSweep.clear();
}
return Promise.resolve();
}
/** 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;
}
/** The window check and the count, in one synchronous step so two hits cannot interleave. */
private count(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);
@@ -55,19 +75,6 @@ export class InMemoryRateLimitStore extends RateLimitStore {
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) {
@@ -34,7 +34,7 @@ export class OauthApiController {
@Get('authorize/validate')
@UseGuards(OptionalJwtGuard)
async validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) {
if (!(await this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now()))) {
throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429);
}
if (!(await this.oauth.mcpEnabled())) {
@@ -100,7 +100,7 @@ export class OauthApiController {
}
const scopes = validation.scopes!;
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
const code = this.oauth.createAuthCode({
const code = await this.oauth.createAuthCode({
clientId: body.client_id,
userId: user.id,
redirectUri: body.redirect_uri,
@@ -37,7 +37,7 @@ export class OauthPublicController {
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) {
if (!(await this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now()))) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
@@ -58,7 +58,7 @@ export class OauthPublicController {
res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
return;
}
const pending = this.oauth.consumeAuthCode(code);
const pending = await this.oauth.consumeAuthCode(code);
const invalidGrant = async (reason: string, userId: number | null) => {
await this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
@@ -155,7 +155,7 @@ export class OauthPublicController {
@Post('revoke')
async revoke(@Req() req: Request, @Res() res: Response): Promise<void> {
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) {
if (!(await this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now()))) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
+1 -1
View File
@@ -162,7 +162,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const pending = this.oauth.consumeAuthCode(code);
const pending = await this.oauth.consumeAuthCode(code);
if (!pending || pending.clientId !== client.client_id)
throw new Error('Authorization grant is invalid.');
+15 -5
View File
@@ -1,5 +1,5 @@
import { RateLimitModule } from '../common/rate-limit.module';
import { Module } from '@nestjs/common';
import { Module, type OnModuleDestroy } from '@nestjs/common';
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
@@ -18,7 +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';
import { PendingCodeStore, pendingCodesSlot, processPendingCodes } from './oauth.pending-codes';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
@@ -40,7 +40,10 @@ import { PendingCodeStore, processPendingCodes } from './oauth.pending-codes';
* 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.
* SDK exchange path reads them back through the same injected singleton. The
* constructor installs whichever store the container resolved in
* pendingCodesSlot, so the sweep and a hand-built OauthService follow a
* swapped provider too.
*
* Exports OauthService for AdminController (admin OAuth-session panel) and the
* MCP transport's token verification.
@@ -67,12 +70,19 @@ import { PendingCodeStore, processPendingCodes } from './oauth.pending-codes';
],
exports: [OauthService],
})
export class OauthModule implements NestModule {
export class OauthModule implements NestModule, OnModuleDestroy {
constructor(
private readonly addons: AddonsService,
private readonly provider: TrekOAuthProvider,
private readonly clients: TrekClientsStore,
) {}
private readonly pendingCodes: PendingCodeStore,
) {
pendingCodesSlot.install(pendingCodes);
}
onModuleDestroy(): void {
pendingCodesSlot.release(this.pendingCodes);
}
configure(consumer: MiddlewareConsumer): void {
const mcpAddonGate = createMcpAddonGate(this.addons);
+35 -25
View File
@@ -1,10 +1,14 @@
import { ProcessStoreSlot } from '../common/process-store-slot';
import { logError } from '../audit/audit-log.logger';
/**
* The short-lived authorization-code store.
*
* 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
* that instance and installs whatever the container resolved in
* pendingCodesSlot, which the sweep and a hand-built OauthService read. 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
@@ -34,58 +38,64 @@ export const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
* 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.
* instance in OauthModule without touching OauthService. Every method returns
* a promise, because such a store lives in the database or on the network.
*/
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;
abstract put(code: string, entry: PendingCode): Promise<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;
abstract take(code: string): Promise<PendingCode | null>;
/** Drop everything past its TTL. */
abstract sweep(now?: number): void;
abstract sweep(now?: number): Promise<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;
put(code: string, entry: PendingCode): Promise<boolean> {
if (this.codes.size >= MAX_PENDING_CODES) return Promise.resolve(false);
this.codes.set(code, entry);
return true;
return Promise.resolve(true);
}
take(code: string): PendingCode | null {
take(code: string): Promise<PendingCode | null> {
const entry = this.codes.get(code);
if (!entry) return null;
if (!entry) return Promise.resolve(null);
this.codes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
if (Date.now() > entry.expiresAt) return Promise.resolve(null);
return Promise.resolve(entry);
}
sweep(now = Date.now()): void {
sweep(now = Date.now()): Promise<void> {
for (const [key, entry] of this.codes) {
if (now > entry.expiresAt) this.codes.delete(key);
}
return Promise.resolve();
}
}
/** The one store this process shares: the consent route writes it, the SDK exchange reads it. */
/** The in-memory store this process starts with, and what OauthModule provides unless overridden. */
export const processPendingCodes: PendingCodeStore = new InMemoryPendingCodeStore();
/**
* The store the readers outside the container use: the import-time sweep and
* a hand-built OauthService. OauthModule installs the one the container
* resolved, so swapping the provider moves them too.
*/
export const pendingCodesSlot = new ProcessStoreSlot<PendingCodeStore>(processPendingCodes);
/** Drop everything past its TTL. Named so the interval body is reachable from a test. */
export function sweepPendingCodes(now = Date.now()): void {
processPendingCodes.sweep(now);
export function sweepPendingCodes(now = Date.now()): Promise<void> {
return pendingCodesSlot.get().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 {
return processPendingCodes.put(code, entry);
/** The interval body: a sweep that fails is logged, never thrown into the timer. */
export function runPendingCodeSweep(now = Date.now()): Promise<void> {
return sweepPendingCodes(now).catch((err: unknown) => {
logError(`OAuth pending-code sweep failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
/** Single-use: the entry is removed even when it turns out to be expired. */
export function takePendingCode(code: string): PendingCode | null {
return processPendingCodes.take(code);
}
setInterval(() => void runPendingCodeSweep(), 60_000).unref();
+6 -6
View File
@@ -35,7 +35,7 @@ import {
redirectUriMatches,
timingSafeEqualHex,
} from './oauth.helpers';
import { AUTH_CODE_TTL_MS, PendingCodeStore, processPendingCodes, type PendingCode } from './oauth.pending-codes';
import { AUTH_CODE_TTL_MS, PendingCodeStore, pendingCodesSlot, type PendingCode } from './oauth.pending-codes';
export type { PendingCode } from './oauth.pending-codes';
@@ -89,7 +89,7 @@ export class OauthService {
@InjectRepository(OauthConsents) private readonly consents: OauthConsentsRepository,
private readonly addons: AddonsService,
private readonly audit: AuditService,
private readonly pendingCodes: PendingCodeStore = processPendingCodes,
private readonly pendingCodes: PendingCodeStore = pendingCodesSlot.get(),
) {}
async mcpEnabled(): Promise<boolean> { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
@@ -231,7 +231,7 @@ export class OauthService {
// Auth code (in-memory, 2-minute TTL)
// -------------------------------------------------------------------------
createAuthCode(params: {
async createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
@@ -239,13 +239,13 @@ export class OauthService {
resource: string | null;
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
}): Promise<string | null> {
const rawCode = randomBytes(32).toString('hex');
const stored = this.pendingCodes.put(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
const stored = await this.pendingCodes.put(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return stored ? rawCode : null;
}
consumeAuthCode(code: string): PendingCode | null {
consumeAuthCode(code: string): Promise<PendingCode | null> {
return this.pendingCodes.take(code);
}
+22 -17
View File
@@ -29,19 +29,20 @@ export interface OidcAuthCode {
* 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.
* OidcModule without touching OidcService. Every method returns a promise,
* because such a store lives in the database or on the network.
*/
export abstract class OidcFlowStore {
abstract putState(state: string, entry: OidcPendingState): void;
abstract putState(state: string, entry: OidcPendingState): Promise<void>;
/** The entry, removed in the same step; null when unknown. */
abstract takeState(state: string): OidcPendingState | null;
abstract putCode(code: string, entry: OidcAuthCode): void;
abstract takeState(state: string): Promise<OidcPendingState | null>;
abstract putCode(code: string, entry: OidcAuthCode): Promise<void>;
/** The entry, removed in the same step, expired or not (the caller decides); null when unknown. */
abstract takeCode(code: string): OidcAuthCode | null;
abstract takeCode(code: string): Promise<OidcAuthCode | null>;
/** Drop states created more than `ttlMs` before `now`. */
abstract sweepStates(now: number, ttlMs: number): void;
abstract sweepStates(now: number, ttlMs: number): Promise<void>;
/** Drop codes created more than `ttlMs` before `now`. */
abstract sweepCodes(now: number, ttlMs: number): void;
abstract sweepCodes(now: number, ttlMs: number): Promise<void>;
}
/** The current behaviour: two maps in this process's memory. */
@@ -49,37 +50,41 @@ 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 {
putState(state: string, entry: OidcPendingState): Promise<void> {
this.states.set(state, entry);
return Promise.resolve();
}
takeState(state: string): OidcPendingState | null {
takeState(state: string): Promise<OidcPendingState | null> {
const entry = this.states.get(state);
if (!entry) return null;
if (!entry) return Promise.resolve(null);
this.states.delete(state);
return entry;
return Promise.resolve(entry);
}
putCode(code: string, entry: OidcAuthCode): void {
putCode(code: string, entry: OidcAuthCode): Promise<void> {
this.codes.set(code, entry);
return Promise.resolve();
}
takeCode(code: string): OidcAuthCode | null {
takeCode(code: string): Promise<OidcAuthCode | null> {
const entry = this.codes.get(code);
if (!entry) return null;
if (!entry) return Promise.resolve(null);
this.codes.delete(code);
return entry;
return Promise.resolve(entry);
}
sweepStates(now: number, ttlMs: number): void {
sweepStates(now: number, ttlMs: number): Promise<void> {
for (const [state, entry] of this.states) {
if (now - entry.createdAt > ttlMs) this.states.delete(state);
}
return Promise.resolve();
}
sweepCodes(now: number, ttlMs: number): void {
sweepCodes(now: number, ttlMs: number): Promise<void> {
for (const [code, entry] of this.codes) {
if (now - entry.created > ttlMs) this.codes.delete(code);
}
return Promise.resolve();
}
}
+5 -5
View File
@@ -69,7 +69,7 @@ export class OidcController {
const query = oidcLoginQuerySchema.safeParse(req.query);
const inviteToken = query.success ? query.data.invite : (req.query.invite as string | undefined);
const remember = query.success && query.data.remember !== undefined ? query.data.remember === '1' : undefined;
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken, remember);
const { state, codeChallenge } = await this.oidc.createState(redirectUri, inviteToken, remember);
// Bind the state to THIS browser. The callback requires a matching cookie,
// so an attacker-initiated login (whose callback URL carries a valid state
// from the shared server map) cannot be replayed in a victim's browser to
@@ -117,7 +117,7 @@ export class OidcController {
// Require the callback to come from the browser that started the flow.
if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state');
const pending = this.oidc.consumeState(state);
const pending = await this.oidc.consumeState(state);
if (!pending) return f('/login?oidc_error=invalid_state');
const config = await this.oidc.getOidcConfig();
@@ -203,7 +203,7 @@ export class OidcController {
// "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927).
const jwtToken = await this.oidc.generateToken(result.user, pending.remember);
const { code: authCode, binding } = this.oidc.createAuthCode(jwtToken, pending.remember);
const { code: authCode, binding } = await this.oidc.createAuthCode(jwtToken, pending.remember);
// Bind the code to THIS browser, the way the state cookie binds the callback.
// The code rides home in a URL, so it is readable from history, from a
// referrer and from anything that logs URLs; without a second half nobody
@@ -219,7 +219,7 @@ export class OidcController {
}
@Get('exchange')
exchange(@Query('code') code: string | undefined, @Req() req: Request, @Res() res: Response): void {
async exchange(@Query('code') code: string | undefined, @Req() req: Request, @Res() res: Response): Promise<void> {
// The binding cookie is single-use like the state cookie: one redemption
// attempt per callback, whatever its outcome.
const binding = (req.cookies as Record<string, string> | undefined)?.[OIDC_EXCHANGE_COOKIE];
@@ -229,7 +229,7 @@ export class OidcController {
res.status(400).json({ error: 'Code required' });
return;
}
const result = this.oidc.consumeAuthCode(code, binding);
const result = await this.oidc.consumeAuthCode(code, binding);
if ('error' in result) {
res.status(400).json({ error: result.error });
return;
+24 -10
View File
@@ -14,6 +14,7 @@ import { TripMembershipService } from '../trip-membership/trip-membership.servic
import { setAuthCookie, RememberOption } from '../common/cookie';
import { AuthService } from '../auth/auth.service';
import { UnitOfWork } from '../database/unit-of-work';
import { logError } from '../audit/audit-log.logger';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository, UserRow } from '../../db/repositories/Users.repository';
@@ -21,7 +22,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';
import { InMemoryOidcFlowStore, OidcFlowStore, type OidcPendingState } from './oidc-flow.store';
// ---------------------------------------------------------------------------
// Types
@@ -247,8 +248,14 @@ export class OidcService implements OnModuleDestroy {
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
private readonly flows: OidcFlowStore = new InMemoryOidcFlowStore(),
) {
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);
const sweepFailed = (err: unknown) =>
logError(`OIDC flow sweep failed: ${err instanceof Error ? err.message : String(err)}`);
this.stateSweeper = setInterval(() => {
this.flows.sweepStates(Date.now(), STATE_TTL).catch(sweepFailed);
}, STATE_CLEANUP);
this.codeSweeper = setInterval(() => {
this.flows.sweepCodes(Date.now(), AUTH_CODE_TTL).catch(sweepFailed);
}, AUTH_CODE_CLEANUP);
}
onModuleDestroy(): void {
@@ -265,15 +272,19 @@ export class OidcService implements OnModuleDestroy {
// Creates the login state and a matching PKCE pair. The verifier stays server
// 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 } {
async createState(
redirectUri: string,
inviteToken?: string,
remember?: boolean,
): Promise<{ 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.flows.putState(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier, remember });
await this.flows.putState(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier, remember });
return { state, codeChallenge };
}
consumeState(state: string) {
consumeState(state: string): Promise<OidcPendingState | null> {
return this.flows.takeState(state);
}
@@ -284,18 +295,21 @@ export class OidcService implements OnModuleDestroy {
* cookie, so redeeming the code takes both halves and only the browser that
* completed the provider handshake has both.
*/
createAuthCode(token: string, remember?: boolean): { code: string; binding: string } {
async createAuthCode(token: string, remember?: boolean): Promise<{ code: string; binding: string }> {
const authCode: string = uuidv4();
const binding = crypto.randomBytes(32).toString('base64url');
this.flows.putCode(authCode, { token, created: Date.now(), remember, bindingHash: sha256Hex(binding) });
await 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 } {
async consumeAuthCode(
code: string,
binding?: string,
): Promise<{ token: string; remember?: boolean } | { error: string }> {
// 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.
const entry = this.flows.takeCode(code);
const entry = await 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
@@ -1,60 +1,69 @@
import { ProcessStoreSlot } from '../common/process-store-slot';
import type { PermissionLevel } from './permissions.service';
/**
* 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.
* it). Every method returns a promise, because a store shared between
* processes lives in the database or on the network and repositories are
* async; the in-memory one below simply resolves at once. Today a permission
* change on one process is only seen by that process, which is right while
* TREK runs as one.
*/
export abstract class PermissionsCacheStore {
/** The cached levels, or null when nothing is cached. */
abstract get(): Map<string, PermissionLevel> | null;
abstract get(): Promise<Map<string, PermissionLevel> | null>;
/** Install a completed read and return it. */
abstract set(next: Map<string, PermissionLevel>): Map<string, PermissionLevel>;
abstract set(next: Map<string, PermissionLevel>): Promise<Map<string, PermissionLevel>>;
/** Drop the cache, so the next reader loads the stored levels again. */
abstract invalidate(): void;
abstract invalidate(): Promise<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;
get(): Promise<Map<string, PermissionLevel> | null> {
return Promise.resolve(this.cache);
}
set(next: Map<string, PermissionLevel>): Map<string, PermissionLevel> {
set(next: Map<string, PermissionLevel>): Promise<Map<string, PermissionLevel>> {
this.cache = next;
return next;
return Promise.resolve(next);
}
invalidate(): void {
invalidate(): Promise<void> {
this.cache = null;
return Promise.resolve();
}
}
/**
* 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.
* The in-memory store this process starts with. PermissionsModule provides
* this instance unless the provider is overridden, so the container's
* PermissionsService and every hand-built one (the no-Nest test harnesses)
* share one cache, as they did when it was a module-level map.
*/
export const processPermissionsCache: PermissionsCacheStore = new InMemoryPermissionsCacheStore();
export function getPermissionsCache(): Map<string, PermissionLevel> | null {
return processPermissionsCache.get();
/**
* The store the readers outside the container use: PermissionsModule installs
* the one the container resolved, so the plain backup restore path
* (backup.impl.ts), which is free functions by design, flushes the same cache
* the request path reads even when the provider is swapped.
*/
export const permissionsCacheSlot = new ProcessStoreSlot<PermissionsCacheStore>(processPermissionsCache);
export function getPermissionsCache(): Promise<Map<string, PermissionLevel> | null> {
return permissionsCacheSlot.get().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> {
return processPermissionsCache.set(next);
export function setPermissionsCache(next: Map<string, PermissionLevel>): Promise<Map<string, PermissionLevel>> {
return permissionsCacheSlot.get().set(next);
}
export function invalidatePermissionsCache(): void {
processPermissionsCache.invalidate();
export function invalidatePermissionsCache(): Promise<void> {
return permissionsCacheSlot.get().invalidate();
}
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { Module, type OnModuleDestroy } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
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';
import { PermissionsCacheStore, permissionsCacheSlot, 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
@@ -16,8 +16,9 @@ import { PermissionsCacheStore, processPermissionsCache } from './permissions-ca
* canAccessTrip (Plan 3c), unaffected by this. */
@Module({
imports: [MikroOrmModule.forFeature([AppSettings])],
// The cache store is the process-wide in-memory one, the same instance the
// backup restore flushes; a shared store replaces it here.
// The cache store is the process-wide in-memory one; a shared store replaces
// it here, and the constructor below hands whichever one the container
// resolved to the readers outside it (the backup restore's flush).
providers: [
PermissionsService,
TripAccessGuard,
@@ -26,4 +27,12 @@ import { PermissionsCacheStore, processPermissionsCache } from './permissions-ca
],
exports: [PermissionsService, TripAccessGuard, TripOwnerGuard],
})
export class PermissionsModule {}
export class PermissionsModule implements OnModuleDestroy {
constructor(private readonly cacheStore: PermissionsCacheStore) {
permissionsCacheSlot.install(cacheStore);
}
onModuleDestroy(): void {
permissionsCacheSlot.release(this.cacheStore);
}
}
@@ -5,7 +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 { PermissionsCacheStore, processPermissionsCache } from './permissions-cache';
import { PermissionsCacheStore, permissionsCacheSlot } from './permissions-cache';
/**
* Permission levels (hierarchical, higher includes lower):
@@ -64,10 +64,9 @@ export const PERMISSION_ACTIONS: PermissionAction[] = [
const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
// 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.
// store shared between processes can be plugged in later. A hand-built
// service takes the store installed in permissionsCacheSlot, the one the
// backup restore path (backup.impl.ts, plain functions, no DI) flushes too.
@Injectable()
@@ -75,11 +74,11 @@ export class PermissionsService {
constructor(
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
private readonly uow: UnitOfWork,
private readonly cacheStore: PermissionsCacheStore = processPermissionsCache,
private readonly cacheStore: PermissionsCacheStore = permissionsCacheSlot.get(),
) {}
private async loadPermissions(): Promise<Map<string, PermissionLevel>> {
const cached = this.cacheStore.get();
const cached = await this.cacheStore.get();
if (cached) return cached;
const cache = new Map<string, PermissionLevel>();
try {
@@ -134,8 +133,8 @@ export class PermissionsService {
return this.cacheStore.set(cache);
}
invalidatePermissionsCache(): void {
this.cacheStore.invalidate();
invalidatePermissionsCache(): Promise<void> {
return this.cacheStore.invalidate();
}
async getPermissionLevel(actionKey: string): Promise<PermissionLevel> {
@@ -173,7 +172,7 @@ export class PermissionsService {
await this.appSettings.setValue(`perm_${actionKey}`, level);
}
});
this.invalidatePermissionsCache();
await this.invalidatePermissionsCache();
return { skipped };
}
@@ -41,7 +41,7 @@ export class PlaceEnrichmentController {
): Promise<MapsPlaceEnrichmentResult> {
// Keyed by user, not by IP: the cost lands on the key of whoever is signed
// in, and a household behind one address should not share a budget.
if (!this.rl.check('place_enrichment', String(user.id), MAX_PER_MINUTE, MINUTE, Date.now())) {
if (!(await this.rl.check('place_enrichment', String(user.id), MAX_PER_MINUTE, MINUTE, Date.now()))) {
throw new HttpException({ error: 'Too many requests' }, 429);
}
@@ -26,9 +26,9 @@ export const PUBLIC_API_RATE_MAX_PER_MINUTE = 120;
* Falls back to a constant key when a request somehow carries no user, so the
* limiter can never end up with one shared bucket for every anonymous caller.
*/
export function enforcePublicApiRateLimit(rl: RateLimitService, req: Request): void {
export async function enforcePublicApiRateLimit(rl: RateLimitService, req: Request): Promise<void> {
const key = `user:${req.user?.id ?? 'unknown'}`;
if (!rl.check('public-api', key, PUBLIC_API_RATE_MAX_PER_MINUTE, PUBLIC_API_RATE_WINDOW_MS, Date.now())) {
if (!(await rl.check('public-api', key, PUBLIC_API_RATE_MAX_PER_MINUTE, PUBLIC_API_RATE_WINDOW_MS, Date.now()))) {
throw new HttpException({ error: 'Too many requests. Please slow down.' }, 429);
}
}
@@ -46,14 +46,14 @@ export class PublicApiController {
private readonly rl: RateLimitService,
) {}
private limit(req: Request): void {
enforcePublicApiRateLimit(this.rl, req);
private limit(req: Request): Promise<void> {
return enforcePublicApiRateLimit(this.rl, req);
}
/** Every trip the token's owner can reach, without itineraries. */
@Get('trips')
async listTrips(@Req() req: Request): Promise<PublicApiTripList> {
this.limit(req);
await this.limit(req);
requireScope(req, 'trips');
return { trips: await this.api.listTrips(requireUserId(req)) };
}
@@ -67,7 +67,7 @@ export class PublicApiController {
*/
@Get('bucket-list')
async listBucketList(@Req() req: Request): Promise<PublicApiBucketList> {
this.limit(req);
await this.limit(req);
requireScope(req, 'bucket-list');
return { items: await this.api.listBucketList(requireUserId(req)) };
}
@@ -85,7 +85,7 @@ export class PublicApiController {
@Param('id') id: string,
@Query('include') include?: string,
): Promise<PublicApiTrip> {
this.limit(req);
await this.limit(req);
requireScope(req, 'trips');
const tripId = parseTripId(id);
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, type OnModuleDestroy } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { RealtimeGateway } from './realtime.gateway';
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
@@ -6,7 +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';
import { processRooms, RoomRegistry, roomsSlot } from './ws-state';
/**
* The transport, kept out of RealtimeModule on purpose.
@@ -30,8 +30,17 @@ import { processRooms, RoomRegistry } from './ws-state';
// Task 2 — handleJoin's own canAccessTrip delegate, now TripsRepository
// directly.
imports: [EphemeralTokenModule, JourneyDomainModule, MikroOrmModule.forFeature([Users, AppSettings, Trips])],
// The room registry is the process-wide in-memory one the broadcast
// functions read too; see RoomRegistry in ws-state.ts.
// The room registry is the process-wide in-memory one; the constructor
// below hands whichever one the container resolved to the broadcast
// functions, so the gateway and the broadcasts never use two registries.
providers: [RealtimeGateway, { provide: RoomRegistry, useValue: processRooms }],
})
export class RealtimeGatewayModule {}
export class RealtimeGatewayModule implements OnModuleDestroy {
constructor(private readonly rooms: RoomRegistry) {
roomsSlot.install(rooms);
}
onModuleDestroy(): void {
roomsSlot.release(this.rooms);
}
}
+9 -7
View File
@@ -23,9 +23,9 @@ import { logError } from '../audit/audit-log.logger';
import {
bookPeers,
broadcastToBook,
processRooms,
registerSocket,
RoomRegistry,
roomsSlot,
socketIdOf,
userOf,
type TrekWebSocket,
@@ -45,9 +45,10 @@ const HEARTBEAT_INTERVAL = 30_000;
* than an interval nobody stops.
*
* 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.
* instance in ws-state.ts (the RoomRegistry port, installed in `roomsSlot` by
* RealtimeGatewayModule) 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
@@ -74,9 +75,10 @@ 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,
// Room membership, behind its port. The container's provider, which
// RealtimeGatewayModule also installs for the broadcast functions in
// ws-state.ts; a hand-built gateway takes the installed one.
private readonly rooms: RoomRegistry = roomsSlot.get(),
) {}
afterInit(server: WebSocketServer): void {
+30 -18
View File
@@ -1,12 +1,15 @@
import { WebSocketServer, WebSocket } from 'ws';
import { emitPluginEvent, pluginEventMeta } from '../../plugin-event-sink';
import { User } from '../../types';
import { ProcessStoreSlot } from '../common/process-store-slot';
/**
* The socket registry: rooms, per-socket identity, and the three fan-out
* primitives.
*
* MODULE state, not provider state, and that is load-bearing. The no-Nest test
* MODULE state, not provider state, and that is load-bearing: the rooms live
* in the registry `roomsSlot` holds, which the container's provider replaces
* when RealtimeGatewayModule is built and nothing else does. The no-Nest test
* harnesses hand-build RealtimeService instances (mcp-test-controllers.ts and
* ~60 unit suites), and the 115 vi.mock('src/websocket') seams assert on these
* exact exports. If the rooms lived on a provider instance, an out-of-container
@@ -31,12 +34,18 @@ export interface TrekWebSocket extends WebSocket {
* 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.
* An injectable port: RealtimeGatewayModule provides it (`processRooms`
* unless overridden) and installs whatever the container resolved in
* `roomsSlot`, which the broadcast functions below read, so the gateway that
* joins sockets and the broadcasts that deliver to them always use one
* registry.
*
* Unlike the other process-state ports this one stays synchronous on purpose.
* Membership holds live sockets, which cannot be kept anywhere but the process
* that owns the connection, and `broadcast()` is a synchronous call every
* domain makes after a write. Running several processes needs a fan-out
* between them (each delivering to its own sockets), which is a port in front
* of `broadcast()`, not a different place to keep these sets.
*/
export abstract class RoomRegistry {
/** Start tracking a new connection's rooms. */
@@ -119,9 +128,12 @@ export class InMemoryRoomRegistry extends RoomRegistry {
}
}
/** The one registry this process has; see RoomRegistry for who reads it. */
/** The in-memory registry this process starts with, and what RealtimeGatewayModule provides unless overridden. */
export const processRooms: RoomRegistry = new InMemoryRoomRegistry();
/** The registry the free functions below use; RealtimeGatewayModule installs the one the container resolved. */
export const roomsSlot = new ProcessStoreSlot<RoomRegistry>(processRooms);
const socketUser = new WeakMap<TrekWebSocket, User>();
const socketId = new WeakMap<TrekWebSocket, number>();
@@ -150,7 +162,7 @@ export function registerSocket(ws: TrekWebSocket, user: User): number {
const sid = nextSocketId++;
socketId.set(ws, sid);
socketUser.set(ws, user);
processRooms.register(ws);
roomsSlot.get().register(ws);
return sid;
}
@@ -159,15 +171,15 @@ export function userOf(ws: TrekWebSocket): User | undefined {
}
export function joinRoom(ws: TrekWebSocket, tripId: number): void {
processRooms.join(ws, tripId);
roomsSlot.get().join(ws, tripId);
}
export function leaveRoom(ws: TrekWebSocket, tripId: number): void {
processRooms.leave(ws, tripId);
roomsSlot.get().leave(ws, tripId);
}
export function leaveAllRooms(ws: TrekWebSocket): void {
processRooms.leaveAll(ws);
roomsSlot.get().leaveAll(ws);
}
// ── Studio books ──────────────────────────────────────────────────────────
@@ -180,16 +192,16 @@ export interface BookPeer {
}
export function joinBook(ws: TrekWebSocket, journeyId: number): void {
processRooms.joinBook(ws, journeyId);
roomsSlot.get().joinBook(ws, journeyId);
}
export function leaveBook(ws: TrekWebSocket, journeyId: number): void {
processRooms.leaveBook(ws, journeyId);
roomsSlot.get().leaveBook(ws, journeyId);
}
/** Every book this socket had open — called when the connection goes. */
export function leaveAllBooks(ws: TrekWebSocket): number[] {
return processRooms.leaveAllBooks(ws);
return roomsSlot.get().leaveAllBooks(ws);
}
/**
@@ -199,7 +211,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 = processRooms.bookMembers(journeyId);
const room = roomsSlot.get().bookMembers(journeyId);
if (!room) return [];
const peers: BookPeer[] = [];
for (const ws of room) {
@@ -224,7 +236,7 @@ export function broadcastToBook(
payload: Record<string, unknown>,
excludeSid?: number,
): void {
const room = processRooms.bookMembers(journeyId);
const room = roomsSlot.get().bookMembers(journeyId);
if (!room || room.size === 0) return;
for (const ws of room) {
if (ws.readyState !== 1) continue;
@@ -259,7 +271,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 = processRooms.members(tripId);
const room = roomsSlot.get().members(tripId);
if (!room || room.size === 0) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
@@ -28,8 +28,8 @@ export class TransitController {
private readonly transit: TransitService,
) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
private async limit(bucket: string, req: Request, max: number): Promise<void> {
if (!(await this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many requests. Please try again later.' }, 429);
}
}
@@ -48,7 +48,7 @@ export class TransitController {
@CurrentUser() user: User,
@Req() req: Request,
) {
this.limit('transit_geocode', req, 300);
await this.limit('transit_geocode', req, 300);
try {
return await this.transit.geocode(q || '', lang, near, user.id);
} catch (err) { this.rethrow(err); }
@@ -66,7 +66,7 @@ export class TransitController {
@CurrentUser() user: User,
@Req() req: Request,
) {
this.limit('transit_plan', req, 60);
await this.limit('transit_plan', req, 60);
try {
return await this.transit.plan({
from: from || '',
+9 -7
View File
@@ -25,9 +25,6 @@ import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helper
import { TransitService } from './transit.service';
const TRANSIT_RATE_WINDOW = 15 * 60 * 1000;
// Deliberately its own instance, separate from the REST controller's: the MCP
// buckets are keyed by userId (mcp_transit_*), the REST ones by req.ip.
const transitRateLimiter = new RateLimitService();
const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
@@ -40,8 +37,8 @@ function errorResult(err: unknown, fallback: string) {
};
}
function rateLimit(userId: number, bucket: string, max: number) {
if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null;
async function rateLimit(limiter: RateLimitService, userId: number, bucket: string, max: number) {
if (await limiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null;
return {
content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }],
isError: true,
@@ -74,6 +71,11 @@ export class TransitMcp {
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
// The limiter RateLimitModule provides, so its store is the one every
// other caller counts in. The MCP buckets (mcp_transit_*) are keyed by
// user and the REST ones (transit_*) by address, so they never share a
// count. A hand-built instance (the MCP test harness) gets its own.
private readonly rl: RateLimitService = new RateLimitService(),
) {}
@Tool({
@@ -95,7 +97,7 @@ export class TransitMcp {
{ query, language, near }: { query: string; language?: string; near?: { lat: number; lng: number } },
ctx: McpContext,
) {
const limited = rateLimit(ctx.userId, 'mcp_transit_geocode', 300);
const limited = await rateLimit(this.rl, ctx.userId, 'mcp_transit_geocode', 300);
if (limited) return limited;
try {
return ok(await this.transit.geocode(query, language, near ? `${near.lat},${near.lng}` : undefined, ctx.userId));
@@ -134,7 +136,7 @@ export class TransitMcp {
},
ctx: McpContext,
) {
const limited = rateLimit(ctx.userId, 'mcp_transit_plan', 60);
const limited = await rateLimit(this.rl, ctx.userId, 'mcp_transit_plan', 60);
if (limited) return limited;
try {
const result = await this.transit.plan({
@@ -75,15 +75,15 @@ export class TripInviteLinkController {
export class TripInviteController {
constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
private limit(req: Request, max: number): void {
if (!this.rl.check('trip_invite', req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
private async limit(req: Request, max: number): Promise<void> {
if (!(await this.rl.check('trip_invite', req.ip || 'unknown', max, RL_WINDOW, Date.now()))) {
throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429);
}
}
@Get(':token')
async preview(@Param('token') token: string, @Req() req: Request) {
this.limit(req, 30);
await this.limit(req, 30);
const resolved = await this.invites.resolve(token);
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
return { trip_id: resolved.trip_id, title: resolved.title };
@@ -92,7 +92,7 @@ export class TripInviteController {
@Post(':token/accept')
@HttpCode(200)
async accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) {
this.limit(req, 20);
await this.limit(req, 20);
const resolved = await this.invites.resolve(token);
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
const result = await this.invites.join(resolved.trip_id, user.id);