refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed

This commit is contained in:
jubnl
2026-09-22 19:39:01 +02:00
parent 7453c7dafa
commit 57f4e29b9a
42 changed files with 1746 additions and 225 deletions
+25
View File
@@ -74,3 +74,28 @@ export function columnIncrementedBy(platform: Platform, ref: string, amount: num
}
return unsupported(platform);
}
/**
* A case-insensitive column reference, for use as a filter's object key:
* `{ [lower(platform, 'email')]: value.toLowerCase() }` produces
* `WHERE LOWER(email) = ?`. This is the parity-preserving equivalent of the
* legacy `LOWER(col) = LOWER(?)` sites (`AuthService`/`OidcService`/
* `UserProfileService`'s username/email CI lookups): the caller lowercases
* the bound value itself (every one of this migration's call sites already
* does, via `String.prototype.toLowerCase`), so the comparison is between
* two already-lowercased values either way — SQLite's `LOWER()` is ASCII-only
* exactly like `toLowerCase()` is for the identifier characters these
* columns hold (usernames/emails), so the two spellings agree on every
* input this codebase stores.
*
* Typed `RawQueryFragment & symbol` (not the bare `RawQueryFragment` the
* other helpers in this file return): TypeScript only accepts a
* `string | number | symbol` as a computed property name, and a raw
* fragment is only usable as an object key because `raw()`'s default
* generic already brands it with a `[Symbol.toPrimitive]`. The other
* helpers here are only ever used as VALUES, where that brand isn't needed.
*/
export function lower(platform: Platform, ref: string): RawQueryFragment & symbol {
if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`);
return unsupported(platform);
}
@@ -37,12 +37,20 @@ export class InviteTokensRepository extends EntityRepository<InviteTokens> {
/**
* `SELECT * FROM invite_tokens WHERE token = ?`
*
* Named `findByToken`, not `findValid` (Task 0 review addendum, LOW item
* 1): the statement has no validity filter of its own — no `expires_at`,
* no `used_count < max_uses` in the WHERE — it is a plain lookup by the
* unique `token` column, and validity (expiry, capacity) is checked in the
* caller's JS afterward, identically for `AuthService.registerUser` (AU8)
* and `OidcService.findOrCreateUser` (O11). A name promising "valid" would
* describe a filter this statement doesn't have.
*
* Not a primary-key filter — `token` is a unique column, not `id` — so this
* always re-queries; no `{ refresh: true }` needed (Task 3a review's
* precision ruling: the identity-map short-circuit only fires for a
* PK-only filter).
*/
async findValid(token: string): Promise<InviteTokenRow | null> {
async findByToken(token: string): Promise<InviteTokenRow | null> {
const invite = await this.findOne({ token });
return invite ? (toRow(invite) as InviteTokenRow) : null;
}
@@ -1,4 +1,6 @@
import type { Users } from '../entities/Users.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { columnIncrementedBy, currentTimestamp, lower } from '../dialect/sql-functions';
import { EntityRepository } from '@mikro-orm/sql';
/**
@@ -13,6 +15,166 @@ import { EntityRepository } from '@mikro-orm/sql';
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
/**
* A full `users` row (`SELECT * FROM users WHERE ...`), as the API emits it.
* Every scalar column of the entity, hand-nullability-annotated the way
* `AppSettingsRepository`/`CategoriesRepository`'s row interfaces are —
* `AssertRowKeys` below fails `tsc` the moment this list and the entity's
* scalar columns drift.
*/
export interface UserRow {
id: number;
username: string;
email: string;
password_hash: string;
role: string;
maps_api_key: string | null;
unsplash_api_key: string | null;
amap_api_key: string | null;
openweather_api_key: string | null;
avatar: string | null;
oidc_sub: string | null;
oidc_issuer: string | null;
last_login: string | null;
mfa_enabled: number | null;
mfa_secret: string | null;
mfa_backup_codes: string | null;
immich_url: string | null;
immich_access_token: string | null;
synology_url: string | null;
synology_username: string | null;
synology_password: string | null;
synology_sid: string | null;
must_change_password: number | null;
password_version: number;
feed_token: string | null;
is_guest: number;
created_at: string | null;
updated_at: string | null;
immich_api_key: string | null;
synology_skip_ssl: number;
synology_did: string | null;
first_seen_version: string;
login_count: number;
immich_auto_upload: number;
airtrail_url: string | null;
airtrail_api_key: string | null;
airtrail_allow_insecure_tls: number | null;
airtrail_write_enabled: number | null;
display_name: string | null;
}
const _userRowKeys: AssertRowKeys<UserRow, Users> = true;
/** `AuthService.registerUser` (AU10) / `OidcService.findOrCreateUser` (O14) — the two column sets `insertUser` covers as one method (Task 1 brief's Required set: "one method, nullable oidc/avatar fields"). */
export interface NewUserRow {
username: string;
email: string;
password_hash: string;
role: string;
first_seen_version: string;
oidc_sub?: string | null;
oidc_issuer?: string | null;
avatar?: string | null;
}
/** `UserProfileService.updateApiKeys` (UP3) — the four encrypted key columns, already resolved by the service. */
export interface ApiKeysPatch {
maps_api_key: string | null;
openweather_api_key: string | null;
unsplash_api_key: string | null;
amap_api_key: string | null;
}
/** `UserProfileService.updateSettings`'s bounded dynamic SET clause (UP7) — a closed key union, never a built SQL string. */
export interface UserProfilePatch {
maps_api_key?: string | null;
openweather_api_key?: string | null;
unsplash_api_key?: string | null;
amap_api_key?: string | null;
username?: string;
email?: string;
}
/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */
export interface UserApiKeyColumns {
role: string;
maps_api_key: string | null;
openweather_api_key: string | null;
unsplash_api_key: string | null;
amap_api_key: string | null;
}
/** `UserProfileService.updateApiKeys`/`updateSettings`'s re-select (UP4/UP8). */
export interface UserProfileWithKeys {
id: number;
username: string;
email: string;
role: string;
maps_api_key: string | null;
openweather_api_key: string | null;
unsplash_api_key: string | null;
amap_api_key: string | null;
avatar: string | null;
mfa_enabled: number | null;
}
/** `UserProfileService.saveAvatar`'s re-select (UP11). */
export interface UserProfileBasic {
id: number;
username: string;
email: string;
role: string;
avatar: string | null;
}
/** `UserProfileService.listUsers` (UP14). */
export interface UserDirectoryRow {
id: number;
username: string;
avatar: string | null;
}
/** `AuthService.getCurrentUser` (AU14). */
export interface CurrentUserRow {
id: number;
username: string;
email: string;
role: string;
avatar: string | null;
oidc_issuer: string | null;
created_at: string | null;
mfa_enabled: number | null;
must_change_password: number | null;
}
/** `jwt-verify.ts#verifyJwtAndLoadUser` (JV1). */
export interface UserWithPasswordVersion {
id: number;
username: string;
email: string;
role: string;
password_version: number;
}
/** `AuthService.requestPasswordReset` (AU36). */
export interface PasswordResetLookup {
id: number;
email: string;
password_hash: string;
oidc_sub: string | null;
}
/** `AuthService.resetPassword`'s reset-target read (AU40). */
export interface ResetTargetRow {
id: number;
email: string;
mfa_enabled: number | null;
mfa_secret: string | null;
mfa_backup_codes: string | null;
password_version: number;
}
export class UsersRepository extends EntityRepository<Users> {
/**
* `SELECT email FROM users WHERE id = ?`
@@ -42,4 +204,611 @@ export class UsersRepository extends EntityRepository<Users> {
const row = await this.findOne({ id: userId }, { fields: [name], refresh: true });
return row ? (row[name] ?? null) : null;
}
// ---------------------------------------------------------------------
// JV1 — jwt-verify.ts
// ---------------------------------------------------------------------
/**
* `SELECT id, username, email, role, password_version FROM users WHERE id = ?`
*
* The canonical session-invalidation read: `verifyJwtAndLoadUser` compares
* the returned `password_version` against the JWT's `pv` claim on every
* request. PK-only `findOne` → `refresh: true` (I1).
*/
async findByIdWithPasswordVersion(id: number): Promise<UserWithPasswordVersion | null> {
const row = await this.findOne(
{ id },
{ fields: ['id', 'username', 'email', 'role', 'password_version'], refresh: true },
);
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null;
}
// ---------------------------------------------------------------------
// AU2 / TK7 / O2 — password_version (session-invalidation gate)
// ---------------------------------------------------------------------
/** `SELECT password_version FROM users WHERE id = ?`. `?? 0` stays in the caller (AU2/TK7/O2 each fall back differently in spirit, identically in value). */
async getPasswordVersion(id: number): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['password_version'], refresh: true });
return row?.password_version ?? null;
}
// ---------------------------------------------------------------------
// AU4 / AU7 / O10 — first-user / admin-role decisions
// ---------------------------------------------------------------------
/** `SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0`. `is_guest` is `INTEGER NOT NULL DEFAULT 0` (verified in the baseline + guest-members migrations) — no row can store NULL there, so `{ is_guest: 0 }` is exact parity for the `COALESCE(is_guest, 0) = 0` guard, not an approximation. */
async countNonGuest(): Promise<number> {
return this.count({ is_guest: 0 });
}
// ---------------------------------------------------------------------
// AU6 — setup_complete
// ---------------------------------------------------------------------
/** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */
async findAdminNeedingPasswordChange(): Promise<number | null> {
const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] });
return row?.id ?? null;
}
// ---------------------------------------------------------------------
// AU9 — registration collision check
// ---------------------------------------------------------------------
/**
* `SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?))
* AND COALESCE(is_guest, 0) = 0`
*/
async findIdByEmailOrUsernameCI(email: string, username: string): Promise<number | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.findOne(
{
$or: [{ [lower(platform, 'email')]: email.toLowerCase() }, { [lower(platform, 'username')]: username.toLowerCase() }],
is_guest: 0,
},
{ fields: ['id'] },
);
return row?.id ?? null;
}
// ---------------------------------------------------------------------
// AU10 / O14 — account creation
// ---------------------------------------------------------------------
/**
* `INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count)
* VALUES (?, ?, ?, ?, ?, 0)` (AU10, local registration) and
* `INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count)
* VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)` (O14, SSO registration) — ONE method
* (Task 1 brief's Required set), the `oidc_sub`/`oidc_issuer`/`avatar`
* columns nullable so AU10's caller simply omits them. `login_count` is
* always the literal `0` on insert, never a parameter, matching both
* statements exactly.
*
* Followed by `refresh` (D4's create rule), not relied-on `RETURNING`:
* the returned row carries the generated `id` and the `defaultRaw`
* `created_at` the same way a legacy INSERT-then-reselect pair did.
*/
async insertUser(row: NewUserRow): Promise<UserRow> {
const user = this.create({
username: row.username,
email: row.email,
password_hash: row.password_hash,
role: row.role,
first_seen_version: row.first_seen_version,
login_count: 0,
oidc_sub: row.oidc_sub ?? null,
oidc_issuer: row.oidc_issuer ?? null,
avatar: row.avatar ?? null,
});
await this.getEntityManager().persist(user).flush();
await this.getEntityManager().refresh(user);
return toRow(user) as UserRow;
}
// ---------------------------------------------------------------------
// AU12 / O4 — email lookup (login + OIDC email-fallback)
// ---------------------------------------------------------------------
/** `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` */
async findByEmailCI(email: string): Promise<UserRow | null> {
const platform = this.getEntityManager().getPlatform();
const user = await this.findOne({ [lower(platform, 'email')]: email.toLowerCase(), is_guest: 0 });
return user ? (toRow(user) as UserRow) : null;
}
// ---------------------------------------------------------------------
// AU13 / AU34 / AU35 / PK12 / O16 — login bookkeeping
// ---------------------------------------------------------------------
/**
* `UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1
* WHERE id = ?` — ONE statement via `nativeUpdate` with a raw sibling-column
* increment (`columnIncrementedBy`), never a read-modify-write: two
* concurrent calls both read `login_count` from the row at commit time, not
* from a value fetched earlier, so both increments land (proven by
* `USERSREPO` `touchLastLogin` concurrency test — two concurrent calls →
* `+2`, never `+1`).
*/
async touchLastLogin(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{ last_login: currentTimestamp(platform), login_count: columnIncrementedBy(platform, 'login_count', 1) },
);
}
// ---------------------------------------------------------------------
// AU14 — /api/auth/me
// ---------------------------------------------------------------------
/** `SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?` */
async findMeRow(id: number): Promise<CurrentUserRow | null> {
const row = await this.findOne(
{ id },
{
fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'],
refresh: true,
},
);
return row
? {
id: row.id,
username: row.username,
email: row.email,
role: row.role,
avatar: row.avatar ?? null,
oidc_issuer: row.oidc_issuer ?? null,
created_at: row.created_at ?? null,
mfa_enabled: row.mfa_enabled ?? null,
must_change_password: row.must_change_password ?? null,
}
: null;
}
// ---------------------------------------------------------------------
// AU15 — current-password check
// ---------------------------------------------------------------------
/** `SELECT password_hash, password_version FROM users WHERE id = ?` */
async getPasswordHashAndVersion(id: number): Promise<{ password_hash: string; password_version: number } | null> {
const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'], refresh: true });
return row ? { password_hash: row.password_hash, password_version: row.password_version } : null;
}
// ---------------------------------------------------------------------
// AU16 / AU43 — password change / reset
// ---------------------------------------------------------------------
/**
* `UPDATE users SET password_hash = ?, must_change_password = 0,
* password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`
* (AU16 `changePassword`, AU43 `resetPassword` — identical statement).
*/
async setPassword(id: number, passwordHash: string, passwordVersion: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{ password_hash: passwordHash, must_change_password: 0, password_version: passwordVersion, updated_at: currentTimestamp(platform) },
);
}
// ---------------------------------------------------------------------
// AU19 / O7 — last-admin guard
// ---------------------------------------------------------------------
/** `SELECT COUNT(*) as count FROM users WHERE role = 'admin'` */
async countAdmins(): Promise<number> {
return this.count({ role: 'admin' });
}
// ---------------------------------------------------------------------
// AU20 / AU22 — admin gate
// ---------------------------------------------------------------------
/** `SELECT role FROM users WHERE id = ?` */
async getRole(id: number): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['role'], refresh: true });
return row?.role ?? null;
}
// ---------------------------------------------------------------------
// AU23 / AU27 / MP2 — MFA state
// ---------------------------------------------------------------------
/**
* `SELECT mfa_enabled FROM users WHERE id = ?`
*
* Returns the ROW, not the bare column: `mfa-policy.guard.ts` (MP2) needs
* to tell "no row for this id" (`null` — the user vanished mid-request,
* `MfaPolicyGuard` lets the request through) apart from "row present with
* `mfa_enabled` unset" (`{ mfa_enabled: null }` — still governed by the
* policy) — the same distinction the legacy `if (!row) return true;`
* before `row.mfa_enabled === 1` made. A bare `T | null` return would
* collapse both into the same value.
*/
async getMfaEnabled(id: number): Promise<{ mfa_enabled: number | null } | null> {
const row = await this.findOne({ id }, { fields: ['mfa_enabled'], refresh: true });
return row ? { mfa_enabled: row.mfa_enabled ?? null } : null;
}
// ---------------------------------------------------------------------
// AU28 — enable MFA
// ---------------------------------------------------------------------
/**
* `UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?,
* updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `secret`/`codes` arrive
* already encrypted/hashed — this repository never touches either.
*/
async enableMfa(id: number, encryptedSecret: string, backupCodes: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{ mfa_enabled: 1, mfa_secret: encryptedSecret, mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) },
);
}
// ---------------------------------------------------------------------
// AU30 / AU32 / PK4 / PK10 / O15 — full-row reads
// ---------------------------------------------------------------------
/**
* `SELECT * FROM users WHERE id = ?` — the full row, reused by every
* caller that needs more than a handful of columns (AU30 `disableMfa`,
* AU32 `verifyMfaLogin`, PK4 `passkeyRegisterOptions`, PK10
* `passkeyLoginVerify`, O15 `findOrCreateUser`'s post-transaction re-select).
* PK-only `findOne` → `refresh: true` (I1).
*/
async findById(id: number): Promise<UserRow | null> {
const user = await this.findOne({ id }, { refresh: true });
return user ? (toRow(user) as UserRow) : null;
}
// ---------------------------------------------------------------------
// AU31 — disable MFA
// ---------------------------------------------------------------------
/** `UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */
async disableMfa(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{ mfa_enabled: 0, mfa_secret: null, mfa_backup_codes: null, updated_at: currentTimestamp(platform) },
);
}
// ---------------------------------------------------------------------
// AU33 / AU44 — backup-code burn
// ---------------------------------------------------------------------
/**
* `UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP
* WHERE id = ?` (AU33, `verifyMfaLogin`'s backup-code branch — inside the
* same transaction as `touchLastLogin`).
*/
async setBackupCodesAndTouch(id: number, backupCodes: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) });
}
/**
* `UPDATE users SET mfa_backup_codes = ? WHERE id = ?` (AU44,
* `resetPassword`'s conditional backup-code splice — no `updated_at`,
* unlike AU33's otherwise-identical statement; two methods, byte-exact
* parity per statement).
*/
async setBackupCodes(id: number, backupCodes: string): Promise<void> {
await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes });
}
// ---------------------------------------------------------------------
// AU36 — password-reset request lookup
// ---------------------------------------------------------------------
/**
* `SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?
* AND COALESCE(is_guest, 0) = 0` — case-SENSITIVE on `email`, unlike
* every other email lookup in this repository (no `LOWER()` in the legacy
* statement here); kept exactly as written, not "fixed" to match the
* others (parity is law).
*/
async findForPasswordReset(email: string): Promise<PasswordResetLookup | null> {
const row = await this.findOne(
{ email, is_guest: 0 },
{ fields: ['id', 'email', 'password_hash', 'oidc_sub'] },
);
return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null;
}
// ---------------------------------------------------------------------
// AU40 — reset-target read
// ---------------------------------------------------------------------
/** `SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?` */
async findResetTarget(id: number): Promise<ResetTargetRow | null> {
const row = await this.findOne(
{ id },
{ fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'], refresh: true },
);
return row
? {
id: row.id,
email: row.email,
mfa_enabled: row.mfa_enabled ?? null,
mfa_secret: row.mfa_secret ?? null,
mfa_backup_codes: row.mfa_backup_codes ?? null,
password_version: row.password_version,
}
: null;
}
// ---------------------------------------------------------------------
// PK15 — re-auth before passkey deletion
// ---------------------------------------------------------------------
/** `SELECT password_hash FROM users WHERE id = ?` */
async getPasswordHash(id: number): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['password_hash'], refresh: true });
return row?.password_hash ?? null;
}
// ---------------------------------------------------------------------
// PK17 — admin passkey-reset target
// ---------------------------------------------------------------------
/** `SELECT id, email FROM users WHERE id = ?` */
async findIdAndEmail(id: number): Promise<{ id: number; email: string } | null> {
const row = await this.findOne({ id }, { fields: ['id', 'email'], refresh: true });
return row ? { id: row.id, email: row.email } : null;
}
// ---------------------------------------------------------------------
// O3 — OIDC primary identity match
// ---------------------------------------------------------------------
/** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */
async findByOidcIdentity(sub: string, issuer: string): Promise<UserRow | null> {
const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer });
return user ? (toRow(user) as UserRow) : null;
}
// ---------------------------------------------------------------------
// O5 / O6 — OIDC identity linking
// ---------------------------------------------------------------------
/**
* `UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?` (O5, the
* email-matched/unlinked/verified-email branch; O6, the provider-switch
* branch — identical statement, two call sites, no `updated_at`).
*/
async linkOidcIdentity(id: number, sub: string, issuer: string): Promise<void> {
await this.nativeUpdate({ id }, { oidc_sub: sub, oidc_issuer: issuer });
}
// ---------------------------------------------------------------------
// O8 — claims-based role change
// ---------------------------------------------------------------------
/** `UPDATE users SET role = ? WHERE id = ?` — no `updated_at` (legacy statement). */
async setRole(id: number, role: string): Promise<void> {
await this.nativeUpdate({ id }, { role });
}
// ---------------------------------------------------------------------
// O9 — OIDC avatar sync
// ---------------------------------------------------------------------
/**
* `UPDATE users SET avatar = ? WHERE id = ?` — no `updated_at`, unlike
* `setAvatar` (UP10/UP13) below, which is the profile-upload path's
* otherwise-identical statement. Two methods, byte-exact parity per
* statement (same rule as `setBackupCodes`/`setBackupCodesAndTouch`).
*/
async setAvatarRaw(id: number, avatar: string | null): Promise<void> {
await this.nativeUpdate({ id }, { avatar });
}
// ---------------------------------------------------------------------
// O12 / UP5 — username collision checks
// ---------------------------------------------------------------------
/**
* `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?
* AND COALESCE(is_guest, 0) = 0` (UP5, `updateSettings`'s own-username
* collision check).
*/
async findIdByUsernameCI(username: string, excludeId: number): Promise<number | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.findOne(
{ [lower(platform, 'username')]: username.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
{ fields: ['id'] },
);
return row?.id ?? null;
}
/**
* `SELECT id FROM users WHERE LOWER(username) = LOWER(?)` (O12, OIDC
* derived-username collision avoidance — no `excludeId`, no guest filter:
* a genuinely different WHERE than UP5's, so a separate method per D4
* rather than an optional parameter that would silently change UP5's
* shape.
*/
async findIdByUsernameCIAny(username: string): Promise<number | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.findOne({ [lower(platform, 'username')]: username.toLowerCase() }, { fields: ['id'] });
return row?.id ?? null;
}
// ---------------------------------------------------------------------
// UP1 — API-key columns read (`currentKeys`)
// ---------------------------------------------------------------------
/** `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` */
async getApiKeyColumns(id: number): Promise<UserApiKeyColumns | null> {
const row = await this.findOne(
{ id },
{ fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'], refresh: true },
);
return row
? {
role: row.role,
maps_api_key: row.maps_api_key ?? null,
openweather_api_key: row.openweather_api_key ?? null,
unsplash_api_key: row.unsplash_api_key ?? null,
amap_api_key: row.amap_api_key ?? null,
}
: null;
}
// ---------------------------------------------------------------------
// UP2 — single-key (maps) update
// ---------------------------------------------------------------------
/** `UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */
async updateMapsKey(id: number, encryptedMapsApiKey: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { maps_api_key: encryptedMapsApiKey, updated_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// UP3 — four-key update
// ---------------------------------------------------------------------
/**
* `UPDATE users SET maps_api_key = ?, openweather_api_key = ?,
* unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP
* WHERE id = ?`. The per-column "encrypt the new value or keep the
* current one" branching (a deleted-row race degrading to a 0-row UPDATE,
* not a crash) is the service's, per D4 — this writes exactly the four
* final values it is given.
*/
async updateApiKeys(id: number, keys: ApiKeysPatch): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { ...keys, updated_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// UP4 / UP8 — profile-with-keys re-select
// ---------------------------------------------------------------------
/** `SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?` */
async findProfileWithKeys(id: number): Promise<UserProfileWithKeys | null> {
const row = await this.findOne(
{ id },
{
fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'],
refresh: true,
},
);
return row
? {
id: row.id,
username: row.username,
email: row.email,
role: row.role,
maps_api_key: row.maps_api_key ?? null,
openweather_api_key: row.openweather_api_key ?? null,
unsplash_api_key: row.unsplash_api_key ?? null,
amap_api_key: row.amap_api_key ?? null,
avatar: row.avatar ?? null,
mfa_enabled: row.mfa_enabled ?? null,
}
: null;
}
// ---------------------------------------------------------------------
// UP6 — email collision check
// ---------------------------------------------------------------------
/** `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0` */
async findIdByEmailCI(email: string, excludeId: number): Promise<number | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.findOne(
{ [lower(platform, 'email')]: email.toLowerCase(), id: { $ne: excludeId }, is_guest: 0 },
{ fields: ['id'] },
);
return row?.id ?? null;
}
// ---------------------------------------------------------------------
// UP7 — bounded dynamic-column profile patch
// ---------------------------------------------------------------------
/**
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
* WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed
* set of 6 possible literal column names, never here: `patchProfile` takes
* only the already-decided final columns as a typed partial and writes
* them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a
* second statement shape for the identical intent) — the entity-API
* equivalent of the legacy's dynamic `SET` list, without ever building SQL
* text. `updated_at` is always stamped, matching the legacy: this method's
* only caller (`UserProfileService.updateSettings`) calls it only when at
* least one field changed (`updates.length > 0`), the same guard the
* legacy statement was built under.
*/
async patchProfile(id: number, changes: UserProfilePatch): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { ...changes, updated_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// UP9 / UP12 — avatar read (before cleanup/replace)
// ---------------------------------------------------------------------
/** `SELECT avatar FROM users WHERE id = ?` */
async getAvatar(id: number): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['avatar'], refresh: true });
return row?.avatar ?? null;
}
// ---------------------------------------------------------------------
// UP10 / UP13 — avatar write
// ---------------------------------------------------------------------
/**
* `UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`
* (UP10 `saveAvatar` with a filename, UP13 `deleteAvatar` with `NULL` —
* same statement shape, `avatar` nullable either way).
*/
async setAvatar(id: number, avatar: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { avatar, updated_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// UP11 — post-avatar-write re-select
// ---------------------------------------------------------------------
/** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */
async findProfileBasic(id: number): Promise<UserProfileBasic | null> {
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'], refresh: true });
return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null;
}
// ---------------------------------------------------------------------
// UP14 — user directory (trip member / contributor pickers)
// ---------------------------------------------------------------------
/** `SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC` */
async listOthersNonGuest(excludeId: number): Promise<UserDirectoryRow[]> {
const rows = await this.find(
{ id: { $ne: excludeId }, is_guest: 0 },
{ fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } },
);
return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null }));
}
// ---------------------------------------------------------------------
// UP15 — key-validation probe
// ---------------------------------------------------------------------
/** `SELECT role, openweather_api_key FROM users WHERE id = ?` */
async getRoleAndWeatherKey(id: number): Promise<{ role: string; openweather_api_key: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'], refresh: true });
return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null;
}
}
@@ -1,4 +1,28 @@
import type { WebauthnCredentials } from '../entities/WebauthnCredentials.entity';
import { EntityRepository } from '@mikro-orm/sql';
export class WebauthnCredentialsRepository extends EntityRepository<WebauthnCredentials> {}
/**
* Plan 3b Task 1 reserves this class (empty stub since Plan 3a) and implements
* exactly the one method the AUTH cluster's Task 1 needs NOW: `mfa-policy.guard.ts`
* (MP3) and, cross-service, `AuthService.updateAppSettings` (AU24, Task 5) both
* read "does this user own at least one passkey" — a passkey satisfies the
* `require_mfa` policy the same way TOTP does. Task 3 (`PasskeyService`)
* extends this class with the rest of the WebAuthn ceremony state
* (`listByUser`, `create`, `findByCredentialId`, `updateCounterAndLastUsed`,
* `renameOwned`, `deleteOwned`, `deleteAllForUser`) — this method's name and
* shape are chosen so nothing here needs to change when that lands.
*/
export class WebauthnCredentialsRepository extends EntityRepository<WebauthnCredentials> {
/**
* `SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1` (MP3/AU24
* — identical statement, two callers). `count` with an implicit `LIMIT 1`
* via `findOne` mirrors the legacy existence probe without pulling a row;
* MikroORM's `count()` has no built-in short-circuit, so `findOne` with a
* narrow `fields` selection is the closer match to "does one exist" than
* `count() > 0`, which would still scan/aggregate every matching row.
*/
async hasAny(userId: number): Promise<boolean> {
const row = await this.findOne({ user: userId }, { fields: ['id'] });
return row !== null;
}
}
+10
View File
@@ -1,5 +1,8 @@
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy';
import mikroOrmConfig from '../mikro-orm.config';
import { AppSettings } from '../db/entities/AppSettings.entity';
import { Users } from '../db/entities/Users.entity';
import { WebauthnCredentials } from '../db/entities/WebauthnCredentials.entity';
import { McpModule } from '../nest-mcp';
import { AccommodationsModule } from './accommodations/accommodations.module';
import { AddonsModule } from './addons/addons.module';
@@ -155,6 +158,13 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
// Not part of DatabaseModule: the e2e suites that compose `DatabaseModule`
// without the ORM would fail on an EntityManager-dependent provider there.
OrmModule,
// MfaPolicyGuard (Plan 3b Task 1) is registered directly below as this
// module's own APP_GUARD, never per-controller — so it is the one place
// that needs `forFeature` for its three repositories; `Users` is not
// otherwise global (JwtAuthGuard and friends inject `EntityManager`
// instead precisely to avoid needing this everywhere — see their own
// docstrings).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials]),
],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
+1 -1
View File
@@ -1097,6 +1097,6 @@ export class AuthService {
* route) should go through.
*/
async verifyJwtToken(token: string): Promise<User | null> {
return verifyJwtAndLoadUser(token);
return verifyJwtAndLoadUser(token, this.usersRepo);
}
}
+10 -1
View File
@@ -1,5 +1,7 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -7,16 +9,23 @@ import { verifyJwtAndLoadUser } from './jwt-verify';
* trek_session cookie (never a Bearer token), so CSRF-sensitive state-changing
* OAuth endpoints (consent submit, client/session mutations) can't be driven by
* a leaked Bearer. Error bodies + codes match the legacy 401 shapes exactly.
*
* `EntityManager` injection, not `@InjectRepository(Users)` — same reasoning
* as `JwtAuthGuard` (Plan 3b Task 1 RULING): the guard is instantiated per the
* HOST controller's module, and using the always-global `EntityManager`
* avoids new `forFeature` wiring in every module that applies it.
*/
@Injectable()
export class CookieAuthGuard implements CanActivate {
constructor(private readonly em: EntityManager) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request & { cookies?: Record<string, string> }>();
const cookieToken = req.cookies?.trek_session;
if (!cookieToken) {
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(cookieToken);
const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users));
if (!user) {
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
}
+13 -4
View File
@@ -1,7 +1,9 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
@@ -28,7 +30,14 @@ import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
*/
@Injectable()
export class GlobalAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
// EntityManager injection, not @InjectRepository(Users) — same reasoning
// as JwtAuthGuard (Plan 3b Task 1 RULING): kept uniform with the other
// three verifyJwtAndLoadUser callers even though this guard, registered
// only as AppModule's own APP_GUARD, would have tolerated either.
constructor(
private readonly reflector: Reflector,
private readonly em: EntityManager,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
@@ -40,7 +49,7 @@ export class GlobalAuthGuard implements CanActivate {
if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) {
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
return true;
}
@@ -57,7 +66,7 @@ export class GlobalAuthGuard implements CanActivate {
];
if (declared.length > 0) {
const declaredToken = extractToken(req);
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken) : null) || null;
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users)) : null) || null;
return true;
}
@@ -65,7 +74,7 @@ export class GlobalAuthGuard implements CanActivate {
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(token);
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+24 -1
View File
@@ -1,5 +1,7 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -9,16 +11,37 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
*
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
*
* Injects `EntityManager`, not `@InjectRepository(Users)` (Plan 3b Task 1
* RULING on `verifyJwtAndLoadUser`'s callers): this guard is applied via
* `@UseGuards(JwtAuthGuard)` on ~70 controllers across nearly every domain
* module in the app, and Nest resolves a class-referenced guard's
* constructor dependencies from the HOST CONTROLLER'S OWN module graph, not
* from a single "the guard's module" — `@InjectRepository(Users)` would need
* `MikroOrmModule.forFeature([Users])` added to every one of those ~70
* modules (and to every partial e2e-testing-module harness that composes a
* guarded controller without the full app), which is the exact blast radius
* D5 exists to avoid. `EntityManager`/`MikroORM`, by contrast, come from
* `MikroOrmModule.forRoot`'s core module, which IS `@Global()` — already
* required wherever a JWT-guarded route is exercised for a real user (the
* ORM owns `users` everywhere else too) — so no module needs new wiring.
* `this.em.getRepository(Users)` inside `canActivate` is the same
* `orm.em.getRepository(Users)`-inside-the-request pattern
* `platform.routes.ts::servePhoto` uses (Task 0); `this.em` here is already
* the request-scoped fork, since `canActivate` only ever runs inside a real
* HTTP request Nest's `@mikro-orm/nestjs` middleware has already wrapped.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly em: EntityManager) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = await verifyJwtAndLoadUser(token);
const user = await verifyJwtAndLoadUser(token, this.em.getRepository(Users));
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+13 -5
View File
@@ -1,8 +1,8 @@
import type { Request } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../../db/database';
import { JWT_SECRET } from '../../config';
import type { User } from '../../types';
import type { UsersRepository } from '../../db/repositories/Users.repository';
/**
* The canonical JWT session check. Every auth surface goes through here — the
@@ -15,6 +15,16 @@ import type { User } from '../../types';
* would have no way to inject one. Note JWT_SECRET is deliberately a live
* binding from src/config, not an app-config value: the admin panel rotates it
* at runtime and `export let` is what makes a rotation take effect in-process.
*
* `verifyJwtAndLoadUser` stays a free function (Plan 3b Task 1 RULING) and
* gains an explicit `users: UsersRepository` parameter — DI over a
* module-level accessor (CLAUDE.md "DI over global mutable module state").
* This file imports neither the ORM nor `RequestContext`: every caller
* resolves its own `UsersRepository` (through Nest DI, or — for the one
* pre-init caller, `platform.routes.ts::servePhoto` — `orm.em.getRepository(Users)`
* resolved INSIDE `applyPlatformUploads`'s `withRequestContext` wrapper,
* `orm` threaded in from `bootstrap.ts`) and passes it in. The legacy `db`
* proxy import is gone.
*/
export function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
@@ -54,16 +64,14 @@ export function decodeSessionClaims(token: string | undefined): SessionClaims |
return decoded as SessionClaims;
}
export async function verifyJwtAndLoadUser(token: string): Promise<User | null> {
export async function verifyJwtAndLoadUser(token: string, users: UsersRepository): Promise<User | null> {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
// secret but are not full session tokens — only their dedicated endpoint
// may accept them, so reject any token carrying a purpose claim here.
if (decoded.purpose) return null;
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
const row = await users.findByIdWithPasswordVersion(decoded.id);
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
+15 -12
View File
@@ -1,11 +1,17 @@
import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { Request } from 'express';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DEMO_EMAILS } from '../common/demo';
import { IS_PUBLIC } from './public.decorator';
import type { User } from '../../types';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
/** Metadata key `@MfaExempt()` writes. */
export const MFA_EXEMPT = 'trek:mfa-exempt';
@@ -44,7 +50,9 @@ type MfaRequest = Request & { user?: User };
@Injectable()
export class MfaPolicyGuard implements CanActivate {
constructor(
private readonly db: DatabaseService,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
@InjectRepository(WebauthnCredentials) private readonly webauthnCredentials: WebauthnCredentialsRepository,
private readonly env: RuntimeEnvService,
private readonly reflector: Reflector,
) {}
@@ -60,24 +68,19 @@ export class MfaPolicyGuard implements CanActivate {
// No session: whoever answers next decides, exactly as before.
if (!user) return true;
const requireRow = this.db.get<{ value: string }>(
"SELECT value FROM app_settings WHERE key = 'require_mfa'",
);
if (requireRow?.value !== 'true') return true;
const requireMfa = await this.appSettings.getValue('require_mfa');
if (requireMfa !== 'true') return true;
if (this.env.isDemoMode() && user.email && DEMO_EMAILS.has(user.email)) return true;
const row = this.db.get<{ mfa_enabled: number | boolean }>(
'SELECT mfa_enabled FROM users WHERE id = ?',
user.id,
);
const row = await this.users.getMfaEnabled(user.id);
if (!row) return true;
// A user-verified passkey is phishing-resistant and inherently two-factor, so
// owning at least one satisfies require_mfa exactly like TOTP does.
// (All stored passkeys were registered with userVerification required.)
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
const passkeyOk = !!this.db.get('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1', user.id);
const mfaOk = row.mfa_enabled === 1;
const passkeyOk = await this.webauthnCredentials.hasAny(user.id);
if (mfaOk || passkeyOk) return true;
if (this.reflector.getAllAndOverride(MFA_EXEMPT, [handler, controller])) return true;
+8 -1
View File
@@ -1,5 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import { Users } from '../../db/entities/Users.entity';
import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
/**
@@ -7,13 +9,18 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
* loaded user when a valid token is present, otherwise leaves it null — and
* always allows the request through (never 401). Used for endpoints whose
* response varies by auth state but don't require it (e.g. /app-config).
*
* `EntityManager` injection, not `@InjectRepository(Users)` — same reasoning
* as `JwtAuthGuard` (Plan 3b Task 1 RULING).
*/
@Injectable()
export class OptionalJwtGuard implements CanActivate {
constructor(private readonly em: EntityManager) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null) || null;
return true;
}
}
+55 -75
View File
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { readEnv, getAppUrl } from '../../app-config';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { StorageService } from '../storage/storage.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
@@ -16,11 +15,10 @@ import {
type InstanceApiKeyName,
} from '../settings/instance-api-keys';
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
import { User } from '../../types';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { UsersRepository, UserApiKeyColumns, UserProfilePatch } from '../../db/repositories/Users.repository';
/**
* The account a user administers about themselves: display settings, avatar,
@@ -30,20 +28,19 @@ import type { UsersRepository } from '../../db/repositories/Users.repository';
* Split out of AuthService, which had grown to 1471 lines by owning identity,
* profile, settings and tokens together. None of this is identity: no password
* is checked here, no session is issued, no MFA secret is touched. Everything
* moved verbatim — same SQL, same validation order, same masking, same error
* strings and status codes.
* moved verbatim — same statement shapes, same validation order, same masking,
* same error strings and status codes.
*
* DatabaseService was the only injected dependency at first, which is what
* made this the second-cheapest cut after tokens; `AppSettingsRepository`/
* `UsersRepository` (Plan 3a Task 5) were added later purely so the three
* `instance-api-keys.ts` calls below pass their own repository rather than
* that file resolving one itself — every other read/write here is still
* `DatabaseService`'s raw SQL (auth is outside Plan 3a).
* Plan 3b Task 1: every read/write here now goes through `UsersRepository`
* (`AppSettingsRepository`/`UsersRepository` were already present, Plan 3a
* Task 5, purely so `instance-api-keys.ts` could take its own repository
* rather than resolving one itself); `DatabaseService` is gone from the
* constructor entirely — nothing in this file reads or writes raw SQL any
* more.
*/
@Injectable()
export class UserProfileService {
constructor(
private readonly db: DatabaseService,
private readonly storage: StorageService,
private readonly uow: UnitOfWork,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@@ -64,12 +61,12 @@ export class UserProfileService {
return readEnv().managed.enabled;
}
/** The three key columns plus the role that decides where a save lands. */
private async currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
);
/**
* The three key columns plus the role that decides where a save lands.
* `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` (UP1).
*/
private async currentKeys(userId: number): Promise<UserApiKeyColumns | null> {
return this.usersRepo.getApiKeyColumns(userId);
}
/**
@@ -81,7 +78,7 @@ export class UserProfileService {
*/
private async storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
current: UserApiKeyColumns | null,
isAdmin: boolean,
): Promise<string> {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
@@ -104,7 +101,7 @@ export class UserProfileService {
*/
private async changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
current: UserApiKeyColumns | null,
isAdmin: boolean,
skipped: string[] = [],
): Promise<string[]> {
@@ -143,10 +140,7 @@ export class UserProfileService {
const isAdmin = current?.role === 'admin';
const changedKeys = await this.changedKeyNames({ maps_api_key }, current, isAdmin);
await this.uow.transactional(async () => {
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
await this.usersRepo.updateMapsKey(userId, maybe_encrypt_api_key(maps_api_key));
await this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
});
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
@@ -163,23 +157,18 @@ export class UserProfileService {
await this.uow.transactional(async () => {
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
userId
);
await this.usersRepo.updateApiKeys(userId, {
maps_api_key: body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
openweather_api_key: body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
unsplash_api_key: body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
amap_api_key: body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
});
await this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
userId
);
const updated = await this.usersRepo.findProfileWithKeys(userId);
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1) } : undefined;
return {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
@@ -203,7 +192,7 @@ export class UserProfileService {
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
}
const conflict = this.db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0', trimmed, userId);
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
if (conflict) return { error: 'Username already taken', status: 409 };
}
@@ -212,24 +201,26 @@ export class UserProfileService {
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
return { error: 'Invalid email format', status: 400 };
}
const conflict = this.db.get('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0', trimmed, userId);
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
if (conflict) return { error: 'Email already taken', status: 409 };
}
const updates: string[] = [];
const params: (string | number | null)[] = [];
// The name and email half of this body stays the user's own in every mode;
// only the three key columns answer to the operator.
const { blocked } = splitManagedKeys(body, this.managed);
const keyLocked = blocked.length > 0;
if (maps_api_key !== undefined && !keyLocked) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
if (openweather_api_key !== undefined && !keyLocked) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (unsplash_api_key !== undefined && !keyLocked) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
if (amap_api_key !== undefined && !keyLocked) { updates.push('amap_api_key = ?'); params.push(maybe_encrypt_api_key(amap_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
// The bounded set of columns UP7's dynamic SET clause could touch — built
// here, at the call site, exactly as the legacy `updates`/`params` arrays
// were; `UsersRepository.patchProfile` writes this typed partial in ONE
// statement, never a built SQL string.
const changes: UserProfilePatch = {};
if (maps_api_key !== undefined && !keyLocked) changes.maps_api_key = maybe_encrypt_api_key(maps_api_key);
if (openweather_api_key !== undefined && !keyLocked) changes.openweather_api_key = maybe_encrypt_api_key(openweather_api_key);
if (unsplash_api_key !== undefined && !keyLocked) changes.unsplash_api_key = maybe_encrypt_api_key(unsplash_api_key);
if (amap_api_key !== undefined && !keyLocked) changes.amap_api_key = maybe_encrypt_api_key(amap_api_key);
if (username !== undefined) changes.username = username.trim();
if (email !== undefined) changes.email = email.trim();
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
@@ -237,21 +228,16 @@ export class UserProfileService {
const isAdmin = current?.role === 'admin';
const changedKeys = keyLocked ? [] : await this.changedKeyNames(body, current, isAdmin, blocked);
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
if (Object.keys(changes).length > 0) {
await this.uow.transactional(async () => {
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
await this.usersRepo.patchProfile(userId, changes);
if (!keyLocked) await this.mirrorInstanceKeys(body, isAdmin);
});
}
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
userId
);
const updated = await this.usersRepo.findProfileWithKeys(userId);
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1) } : undefined;
return {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
@@ -261,10 +247,7 @@ export class UserProfileService {
}
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
);
const user = await this.usersRepo.getApiKeyColumns(userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
// The one endpoint in the codebase that hands back a stored key in the
@@ -303,29 +286,29 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async saveAvatar(userId: number, filename: string) {
const current = this.db.get<{ avatar: string | null }>('SELECT avatar FROM users WHERE id = ?', userId);
const current = await this.usersRepo.getAvatar(userId);
// Only a locally uploaded file has something to clean up. An OIDC picture URL
// (#1399) has no storage object, so skip the delete entirely.
if (current?.avatar && !/^https:\/\//i.test(current.avatar)) {
if (current && !/^https:\/\//i.test(current)) {
// Fire-and-forget parity: leftover objects are harmless; the DB update is
// the source of truth for which avatar is current. The catch also
// swallows a hostile stored value the central key validation rejects.
await this.storage.delete('avatars', current.avatar).catch(() => {});
await this.storage.delete('avatars', current).catch(() => {});
}
this.db.run('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', filename, userId);
await this.usersRepo.setAvatar(userId, filename);
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'avatar'>>('SELECT id, username, email, role, avatar FROM users WHERE id = ?', userId);
const updated = await this.usersRepo.findProfileBasic(userId);
return { success: true, avatar_url: avatarUrl(updated || {}) };
}
async deleteAvatar(userId: number) {
const current = this.db.get<{ avatar: string | null }>('SELECT avatar FROM users WHERE id = ?', userId);
const current = await this.usersRepo.getAvatar(userId);
// An OIDC picture URL (#1399) has no storage object — only delete an uploaded one.
if (current?.avatar && !/^https:\/\//i.test(current.avatar)) {
await this.storage.delete('avatars', current.avatar).catch(() => {});
if (current && !/^https:\/\//i.test(current)) {
await this.storage.delete('avatars', current).catch(() => {});
}
this.db.run('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', userId);
await this.usersRepo.setAvatar(userId, null);
return { success: true };
}
@@ -336,10 +319,7 @@ export class UserProfileService {
async listUsers(excludeUserId: number) {
// The global user directory feeds the trip member-add / contributor pickers —
// guests (#1362) are trip-scoped and must never be selectable here.
const users = this.db.all<Pick<User, 'id' | 'username' | 'avatar'>>(
'SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC',
excludeUserId
);
const users = await this.usersRepo.listOthersNonGuest(excludeUserId);
return users.map(u => ({ ...u, avatar_url: avatarUrl(u) }));
}
@@ -348,7 +328,7 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
const user = await this.usersRepo.getRoleAndWeatherKey(userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { getAppUrl, readEnv } from '../../app-config';
import { DatabaseService } from '../database/database.service';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
function hostOf(url: string): string | null {
try {
@@ -68,10 +70,11 @@ export interface WebauthnConfig {
*/
@Injectable()
export class WebauthnConfigService {
constructor(private readonly db: DatabaseService) {}
constructor(@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository) {}
/** `SELECT value FROM app_settings WHERE key = ?` (WC1), through `AppSettingsRepository.getValue` (Plan 3a). */
private async setting(key: string): Promise<string | null> {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value;
const raw = (await this.appSettings.getValue(key)) ?? undefined;
const trimmed = raw?.trim();
return trimmed ? trimmed : null;
}
+8 -1
View File
@@ -8,6 +8,8 @@ import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
import { verifyJwtAndLoadUser } from '../auth/jwt-verify';
import { EntityManager } from '@mikro-orm/core';
import { Users } from '../../db/entities/Users.entity';
import type { User, TripFile } from '../../types';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants';
@@ -79,6 +81,11 @@ export class FilesService {
private readonly realtime: RealtimeService,
private readonly tokens: EphemeralTokenService,
private readonly storage: StorageService,
// EntityManager, not @InjectRepository(Users) — same reasoning as
// JwtAuthGuard (Plan 3b Task 1 RULING on verifyJwtAndLoadUser's callers):
// kept uniform with the guards rather than adding a one-off
// MikroOrmModule.forFeature([Users]) to FilesModule for this single call.
private readonly em: EntityManager,
) {}
async verifyTripAccess(tripId: string | number, userId: number) {
@@ -110,7 +117,7 @@ export class FilesService {
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = await verifyJwtAndLoadUser(jwtToken);
const user = await verifyJwtAndLoadUser(jwtToken, this.em.getRepository(Users));
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
}
+18 -11
View File
@@ -8,6 +8,8 @@ import { db } from '../../db/database';
import { withRequestContext } from '../database/request-context';
import { StorageService } from '../storage/storage.service';
import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } from '../storage/storage.types';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
@@ -72,7 +74,7 @@ export function storageStaticHandler(storage: StorageService, category: StorageC
};
}
async function servePhoto(storage: StorageService, req: Request, res: Response): Promise<void> {
async function servePhoto(storage: StorageService, req: Request, res: Response, users: UsersRepository): Promise<void> {
const safeName = path.basename(req.params.filename);
// Parity: after basename(), the old resolve()+startsWith guard could only
// fire when the remaining segment was '..' — keep that exact 403.
@@ -104,7 +106,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response):
}
// JWT session path (with pv check).
const user = await verifyJwtAndLoadUser(rawToken);
const user = await verifyJwtAndLoadUser(rawToken, users);
if (user) return sendPhoto();
// Share-token path: require the token to cover the exact trip the
@@ -132,14 +134,19 @@ async function servePhoto(storage: StorageService, req: Request, res: Response):
* mounted here, on the raw Express instance, BEFORE `app.init()` — every
* `/uploads/photos/*` request runs this handler entirely outside Nest's
* per-request EntityManager fork, forever, not just during a boot window.
* `verifyJwtAndLoadUser` (`jwt-verify.ts`) reads `users` through the legacy
* `db` proxy today, so nothing here needs the ORM yet — but the inventory's
* proposed conversion (`UsersRepository.findByIdWithPasswordVersion`, Task 1)
* would throw `cannotUseGlobalContext` on every photo request the instant it
* lands, because nothing forks a context for this pre-init route. Wrapping
* the handler in `withRequestContext` NOW, before any repository call exists
* on this path, means Task 1 converts `jwt-verify.ts` without touching this
* file again and without a single broken photo request in between.
* `verifyJwtAndLoadUser` (`jwt-verify.ts`, JV1) now reads `users` through
* `UsersRepository` (Plan 3b Task 1) rather than the legacy `db` proxy — the
* exact case Task 0's wrap was built for. `orm.em.getRepository(Users)` is
* resolved inside the arrow passed to `withRequestContext` below: `orm.em`
* is the global, context-resolving EntityManager, and MikroORM's repository
* object holds a reference to that same proxy rather than a snapshot, so a
* query issued through it later resolves whatever `AsyncLocalStorage`
* context is active AT QUERY TIME — not at the moment `getRepository()` was
* called. What actually gates this is `withRequestContext` wrapping the
* whole `servePhoto(...)` call (query included): removing the wrapper
* entirely reproduces Task 0's `ValidationError: Using global EntityManager
* instance...` (verified directly — mutation-proof in `task-1-report.md`),
* exactly the failure `PHOTOCTX-002`/`SEAM-002` guard against.
*
* Optional at the type level only, the same shape `TrekWsAdapter` uses for
* its own D6 wrapper (`src/nest/realtime/trek-ws.adapter.ts`): `bootstrap.ts`
@@ -184,7 +191,7 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
next(new Error('applyPlatformUploads: no MikroORM available to build a request context for /uploads/photos/*'));
return;
}
return withRequestContext(orm, () => servePhoto(storage, req, res)).catch(next);
return withRequestContext(orm, () => servePhoto(storage, req, res, orm.em.getRepository(Users))).catch(next);
});
// Block direct access to /uploads/files
@@ -1,9 +1,11 @@
import { All, Controller, Param, Req, Res } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/core';
import type { Request, Response } from 'express';
import { extractToken, verifyJwtAndLoadUser } from '../auth/jwt-verify';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
import { Public } from '../auth/public.decorator';
import { Users } from '../../db/entities/Users.entity';
/**
* Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2).
@@ -74,7 +76,12 @@ function toRelativeLocation(loc: unknown): string | null {
@Public('auth is data-driven per plugin route (route.auth in the manifest), asserted in the handler')
@Controller('api/plugins/:pluginId')
export class PluginsProxyController {
constructor(private readonly runtime: PluginRuntimeService) {}
// EntityManager, not @InjectRepository(Users) — same reasoning as
// JwtAuthGuard (Plan 3b Task 1 RULING on verifyJwtAndLoadUser's callers).
constructor(
private readonly runtime: PluginRuntimeService,
private readonly em: EntityManager,
) {}
@All('*path')
async proxy(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
@@ -95,7 +102,7 @@ export class PluginsProxyController {
let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null;
if (route.auth) {
const token = extractToken(req);
const loaded = token ? await verifyJwtAndLoadUser(token) : null;
const loaded = token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users)) : null;
if (!loaded) {
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
return;
@@ -89,6 +89,15 @@ function withinBurst(socket: TrekWebSocket): boolean {
* `error` event on the socket; unhandled, Node rethrows it and the process
* dies. That is #1576, and it only reproduces against a hostile client, so
* nothing in CI would catch its removal.
*
* All three per-connection lifecycle hooks the base adapter offers —
* `bindClientConnect` (`OnGatewayConnection`), `bindMessageHandlers`
* (`@SubscribeMessage`) and `bindClientDisconnect` (`OnGatewayDisconnect`) —
* are overridden here to run their callback inside `withRequestContext`
* (D6): none of the three has an HTTP request behind it, so none gets a
* forked EntityManager unless this adapter forks one itself. `create`
* above and `close` below are the only other overrides, and neither
* dispatches into gateway/handler code, so neither needs the wrap.
*/
export class TrekWsAdapter extends WsAdapter {
private readonly httpServerRef: HttpServer;
@@ -184,6 +193,26 @@ export class TrekWsAdapter extends WsAdapter {
});
}
/**
* Dispatches Nest's `OnGatewayDisconnect` hook (`RealtimeGateway.handleDisconnect`)
* the same way `bindClientConnect` above dispatches `OnGatewayConnection` —
* the base `WsAdapter.bindClientDisconnect` just does `client.on(CLOSE_EVENT,
* callback)` with no context of any kind. `handleDisconnect` reads no
* repository today (Task 0 review addendum, LOW item 2), but it is a
* lifecycle hook with no HTTP request behind it exactly like the connect and
* message hooks are, so it gets the same wrap NOW rather than becoming the
* next "the adapter is done" surprise the moment a future change makes it
* read one. Same fail-closed shape as the other two: throw before calling.
*/
bindClientDisconnect(client: TrekWebSocket, callback: (...args: unknown[]) => void): void {
client.on('close', (...args: unknown[]) => {
if (!this.orm) {
throw new Error('TrekWsAdapter: no MikroORM available to build a request context for this disconnect');
}
withRequestContext(this.orm, () => callback(...args));
});
}
bindMessageHandlers(
socket: TrekWebSocket,
handlers: MessageMappingProperties[],