import { Inject, Injectable } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { REDIS_CLIENT, type Redis } from '../redis/redis.token'; /** * Secondary index `user_sessions:{userId}` → Redis set of session * ids the user currently has open (per ADR-0010 §"Revocation"). * * Maintained on the lifecycle events: * - session created in `/auth/callback` → `SADD` * - session destroyed in `/auth/logout` → `SREM` * - session destroyed by the absolute-timeout → `SREM` * * NOT actively cleaned on idle-TTL expiry: when the Redis session * key disappears on its own, the corresponding entry in the user * set becomes a "phantom" id pointing at nothing. ADR-0010 treats * this as best-effort — orphans are filtered out by `list()` / * future admin "logout everywhere" routes. * * Today the only consumer of `add` / `remove` is the auth surface. * `list` and the matching admin endpoint land in a follow-up PR * (admin module / `@RequireAdmin` guard). */ @Injectable() export class UserSessionIndexService { private static readonly KEY_PREFIX = 'user_sessions:'; constructor( @Inject(REDIS_CLIENT) private readonly redis: Redis, private readonly logger: Logger, ) {} async add(userId: string, sessionId: string): Promise { try { await this.redis.sadd(this.keyFor(userId), sessionId); } catch (err) { // Best-effort by design: a failed SADD leaves the session // unreachable from "logout everywhere" but does not break // sign-in itself. Log so ops can spot a degraded Redis. this.logger.warn( { event: 'user_session_index.add_failed', userId, message: err instanceof Error ? err.message : String(err), }, 'UserSessionIndex', ); } } async remove(userId: string, sessionId: string): Promise { try { await this.redis.srem(this.keyFor(userId), sessionId); } catch (err) { this.logger.warn( { event: 'user_session_index.remove_failed', userId, message: err instanceof Error ? err.message : String(err), }, 'UserSessionIndex', ); } } /** * Returns the raw set of session ids for a user. Includes * orphans (entries whose `session:` Redis key has already * expired); the caller is expected to filter or rely on idle * cleanup. v1 has no in-product consumer — exposed for tests * today and the upcoming admin module. */ async list(userId: string): Promise { return this.redis.smembers(this.keyFor(userId)); } private keyFor(userId: string): string { return `${UserSessionIndexService.KEY_PREFIX}${userId}`; } }