refactor(auth): AuthService and account cleanup through repositories

This commit is contained in:
jubnl
2026-09-22 23:17:17 +02:00
parent 21ade19d44
commit 33b9488653
19 changed files with 680 additions and 210 deletions
@@ -1,4 +1,116 @@
import type { PasswordResetTokens } from '../entities/PasswordResetTokens.entity';
import type { AssertRowKeys } from './_shared/rows';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class PasswordResetTokensRepository extends TrekRepository<PasswordResetTokens> {}
/** A `password_reset_tokens` row as the API emits it. */
export interface PasswordResetTokenRow {
id: number;
user_id: number;
token_hash: string;
expires_at: string;
consumed_at: string | null;
created_at: string | null;
created_ip: string | null;
}
const _passwordResetTokenRowKeys: AssertRowKeys<PasswordResetTokenRow, PasswordResetTokens> = true;
/** `AuthService.requestPasswordReset`'s write (AU38) — the raw token is never given to or stored by this repository, only its SHA-256 hash. */
export interface NewPasswordResetTokenRow {
user_id: number;
token_hash: string;
expires_at: string;
created_ip: string | null;
}
/** `AuthService.resetPassword`'s lookup (AU39). */
export interface PasswordResetTokenLookup {
id: number;
user_id: number;
expires_at: string;
consumed_at: string | null;
}
/**
* The password-reset token store — `AuthService.requestPasswordReset`/
* `resetPassword` (Plan 3b Task 5, AU37–AU42). Expiry is always compared in
* JS by the caller, exactly as before; this repository never filters on
* `expires_at` itself.
*/
export class PasswordResetTokensRepository extends TrekRepository<PasswordResetTokens> {
/**
* `UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE
* user_id = ? AND consumed_at IS NULL` (AU37, `requestPasswordReset` —
* invalidates every prior live token for the user, NOT wrapped in a
* transaction with the INSERT that follows it — legacy behaviour, kept
* exactly, see the inventory's surprise #6) and, with `exceptId` supplied,
* `... AND id != ?` (AU42, `resetPassword` — burns every OTHER live token,
* inside the same `uow.transactional` block as the password write). ONE
* method with an optional exclusion (Task 5 brief ruling) rather than two,
* since the WHERE differs only by that one extra clause.
*/
async consumeAllLiveForUser(userId: number, exceptId?: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
exceptId === undefined
? { user: userId, consumed_at: null }
: { user: userId, consumed_at: null, id: { $ne: exceptId } },
{ consumed_at: currentTimestamp(platform) },
);
}
/**
* `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at,
* created_ip) VALUES (?, ?, ?, ?)` (AU38). Hash-only storage: the caller
* (`AuthService`) hashes the raw token with SHA-256 before calling this —
* the raw value is never given to or returned by this repository.
* `EntityRepository.insert()`, not `create()` + `persist().flush()` —
* same rationale as every other repository's insert method in this
* program (`WebauthnChallengesRepository.insertChallenge`'s docstring):
* a native insert has no identity-map/UnitOfWork side effects, while
* `persist().flush()` commits the entire request's UnitOfWork.
*/
async insertToken(row: NewPasswordResetTokenRow): Promise<void> {
await this.insert({
user: row.user_id,
token_hash: row.token_hash,
expires_at: row.expires_at,
created_ip: row.created_ip,
});
}
/**
* `SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens
* WHERE token_hash = ?` (AU39, `resetPassword`'s lookup —
* **security-sensitive**). Not a primary-key filter (`token_hash` is a
* unique column, not `id`) — `disableIdentityMap: true` applies all the
* same, by the base class's default (`_shared/trek-repository.ts`): the
* same row is touched again moments later in the same request by
* `markConsumed`/`consumeAllLiveForUser`'s `nativeUpdate`s, inside
* `uow.transactional` — an identity-mapped read here is exactly the
* stale-write-back shape `Users.repository.ts`'s class docstring
* describes (proven for this table by `PWDRESETREPO-012` below).
*
* `fields` names the relation property `'user'`, not the `persist(false)`
* mirror `'user_id'` (same reasoning as `McpTokensRepository.findBasic`'s
* docstring): a `fields` list naming the mirror directly leaves
* `row.user_id` `undefined`, while naming the relation loads its
* (unpopulated) `Ref`, whose `.id` reads synchronously with no second
* query.
*/
async findByTokenHash(hash: string): Promise<PasswordResetTokenLookup | null> {
const row = await this.findOne({ token_hash: hash }, { fields: ['id', 'user', 'expires_at', 'consumed_at'] });
return row ? { id: row.id, user_id: row.user.id, expires_at: row.expires_at, consumed_at: row.consumed_at ?? null } : null;
}
/**
* `UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE
* id = ?` (AU41, `resetPassword` — burns the token being used, first
* statement inside the password-change transaction).
*/
async markConsumed(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { consumed_at: currentTimestamp(platform) });
}
}
@@ -859,4 +859,24 @@ export class UsersRepository extends TrekRepository<Users> {
const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'] });
return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null;
}
// ---------------------------------------------------------------------
// UC11 — account erasure (Plan 3b Task 1 review, "contract gaps" item 2:
// named in the plan's Task 1 bullet, deliberately deferred to Task 5 —
// the ONE pre-authorised UsersRepository addition this task makes).
// ---------------------------------------------------------------------
/**
* `DELETE FROM users WHERE id = ?` — `UserCleanupService.deleteUserCompletely`'s
* final statement, the root of its 13-table transaction
* (**security-sensitive**: account erasure). Named `deleteById`, not
* `remove`/`delete`: `EntityRepository#remove` is a real (persist-marking,
* needs-a-`flush()`) method on the base class — shadowing it would change
* its signature and behaviour; `deleteById` matches the naming this
* program already uses for a native, immediate delete
* (`McpTokensRepository.deleteById`).
*/
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
}
+11 -5
View File
@@ -6,6 +6,9 @@ import { Users } from '../../db/entities/Users.entity';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import { WebauthnChallenges } from '../../db/entities/WebauthnChallenges.entity';
import { InviteTokens } from '../../db/entities/InviteTokens.entity';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import { PasswordResetTokens } from '../../db/entities/PasswordResetTokens.entity';
import { TokensModule } from '../tokens/tokens.module';
import { AuthPublicController } from './auth-public.controller';
import { AuthController } from './auth.controller';
@@ -73,11 +76,14 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
// AppSettingsRepository/UsersRepository to instance-api-keys.ts's
// resolveApiKey/readInstanceApiKey/writeInstanceApiKey now (Plan 3a Task
// 5). WebauthnCredentials/WebauthnChallenges: PasskeyService (Plan 3b
// Task 3). InviteTokens: RegistrationInvitesService (Plan 3b Tasks 0/3)
// — everything else either service still reads/writes is raw SQL
// through DatabaseService (AuthService's own conversion is a later
// task).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens])],
// Task 3). InviteTokens: RegistrationInvitesService (Plan 3b Tasks 0/3).
// McpTokens/OauthTokens: AuthService's cross-domain session-revocation
// writes on password change/reset (AU17/AU18/AU45/AU46) — the entities
// are owned by TokensModule/OauthModule, but `@InjectRepository`
// resolves from THIS module's own `forFeature` graph regardless of who
// else also registers it (`tokens.module.ts`'s own docstring explains
// why). PasswordResetTokens: AuthService's own table (Plan 3b Task 5).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens])],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
+164 -151
View File
@@ -9,7 +9,6 @@ import { randomBytes, createHash } from 'crypto';
import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
@@ -21,6 +20,16 @@ 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 { InviteTokens } from '../../db/entities/InviteTokens.entity';
import type { InviteTokensRepository } from '../../db/repositories/InviteTokens.repository';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import type { McpTokensRepository } from '../../db/repositories/McpTokens.repository';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../db/repositories/OauthTokens.repository';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
import { PasswordResetTokens } from '../../db/entities/PasswordResetTokens.entity';
import type { PasswordResetTokensRepository } from '../../db/repositories/PasswordResetTokens.repository';
// Type-and-guard only: the app-config read reports the provider choice, it does
// not construct one, so this does not pull the maps domain into auth.
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
@@ -34,6 +43,7 @@ import { splitManagedKeys } from '../common/managed';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { verifyJwtAndLoadUser } from './jwt-verify';
import { User } from '../../types';
import type { UserRow } from '../../db/repositories/Users.repository';
import { DEMO_EMAIL_PRIMARY, DEMO_PASS, isDemoEmail } from '../common/demo';
import { avatarUrl } from '../common/avatarUrl';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
@@ -113,14 +123,40 @@ export interface ResetPasswordOutcome {
userId?: number;
}
/**
* `UsersRepository.findByEmailExact`/`findByEmailCI`/`findById` return the
* repository's full-row shape (`UserRow` — every `users` column, `role:
* string`, several `T | null` columns); the client-payload helper
* `stripUserForClient` takes the narrower `User` contract type (`role:
* 'admin' | 'user'`, those same columns `T | undefined`). Same mapping as
* `passkey.service.ts`'s own `toClientUser` (Plan 3b Task 3 review, F5) —
* kept as a second, file-local copy rather than exported/shared, matching
* that precedent's own reasoning (one small mapping, cheaper to duplicate
* than to add a cross-file dependency for).
*/
function toClientUser(row: UserRow): User {
return {
...row,
role: row.role === 'admin' ? 'admin' : 'user',
mfa_enabled: row.mfa_enabled ?? undefined,
must_change_password: row.must_change_password ?? undefined,
created_at: row.created_at ?? undefined,
updated_at: row.updated_at ?? undefined,
};
}
/**
* DI-native auth domain service. The SQL moved 1:1 from the legacy
* src/services/authService.ts (same statements, same `||` falsy-coercion
* defaults, same post-write re-selects, same error strings); the pure
* password/backup-code crypto lives in auth.helpers.ts. PermissionsService is
* injected (it replaced the permissions.bridge import); the JWT cookie
* set/clear, the reset-email delivery and the remaining legacy helpers keep
* their plain imports. Non-Nest consumers (legacy MCP registrars, legacy
* defaults, same post-write re-selects, same error strings) onto the
* repositories built across Plan 3a/3b — every statement below is now a
* repository method call instead of a `this.db.*` invocation, same shape,
* same order, same transaction scope (`.superpowers/sdd/2026-09-22-orm-phase3b/
* task-5-report.md`); the pure password/backup-code crypto stays in
* auth.helpers.ts. PermissionsService is injected (it replaced the
* permissions.bridge import); the JWT cookie set/clear, the reset-email
* delivery and the remaining legacy helpers keep their plain imports.
* Non-Nest consumers (legacy MCP registrars, legacy
* adminService/oidcService/passkeyService) go through auth.bridge.ts.
*
* AtlasService is deliberately NOT injected any more: getTravelStats, its only
@@ -130,7 +166,6 @@ export interface ResetPasswordOutcome {
@Injectable()
export class AuthService {
constructor(
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly membership: TripMembershipService,
private readonly webauthn: WebauthnConfigService,
@@ -141,6 +176,11 @@ export class AuthService {
private readonly uow: UnitOfWork,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
@InjectRepository(InviteTokens) private readonly inviteTokens: InviteTokensRepository,
@InjectRepository(McpTokens) private readonly mcpTokens: McpTokensRepository,
@InjectRepository(OauthTokens) private readonly oauthTokens: OauthTokensRepository,
@InjectRepository(WebauthnCredentials) private readonly webauthnCredentials: WebauthnCredentialsRepository,
@InjectRepository(PasswordResetTokens) private readonly passwordResetTokens: PasswordResetTokensRepository,
) {}
// Cookie
@@ -162,24 +202,24 @@ export class AuthService {
oidc_registration: boolean;
passkey_login: boolean;
}> {
const get = (key: string) =>
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key)?.value ?? null;
const get = (key: string) => this.appSettings.getValue(key);
// Passkey login is independent of the password/OIDC "new keys" probe, so it
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
// that never touched the password/OIDC toggles it would silently read false
// even after an admin enabled it. Default OFF (opt-in).
const passkey_login = get('passkey_login') === 'true';
const passkey_login = (await get('passkey_login')) === 'true';
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
.some(k => get(k) !== null);
const hasNewKeys = (
await Promise.all(['password_login', 'password_registration', 'oidc_login', 'oidc_registration'].map((k) => get(k)))
).some((v) => v !== null);
if (hasNewKeys) {
const result = {
password_login: get('password_login') !== 'false',
password_registration: get('password_registration') !== 'false',
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
password_login: (await get('password_login')) !== 'false',
password_registration: (await get('password_registration')) !== 'false',
oidc_login: (await get('oidc_login')) !== 'false',
oidc_registration: (await get('oidc_registration')) !== 'false',
passkey_login,
};
if (readEnv().oidc.only) {
@@ -190,13 +230,13 @@ export class AuthService {
}
// Legacy fallback
const oidcOnlyEnabled = readEnv().oidc.only || get('oidc_only') === 'true';
const oidcOnlyEnabled = readEnv().oidc.only || (await get('oidc_only')) === 'true';
const oidcConfigured = !!(
(readEnv().oidc.issuer || get('oidc_issuer')) &&
(readEnv().oidc.clientId || get('oidc_client_id'))
(readEnv().oidc.issuer || (await get('oidc_issuer'))) &&
(readEnv().oidc.clientId || (await get('oidc_client_id')))
);
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
const allowReg = (get('allow_registration') ?? 'true') === 'true';
const allowReg = ((await get('allow_registration')) ?? 'true') === 'true';
return {
password_login: !oidcOnly,
@@ -214,7 +254,7 @@ export class AuthService {
async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) {
const pv = typeof user.password_version === 'number'
? user.password_version
: (this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', user.id)?.password_version ?? 0);
: ((await this.usersRepo.getPasswordVersion(Number(user.id))) ?? 0);
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
// the cookie service decides session-vs-persistent off the same flag.
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
@@ -257,13 +297,13 @@ export class AuthService {
if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true;
const env = readEnv();
const declaredRpId = (
env.webauthn.rpId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'webauthn_rp_id'")?.value
env.webauthn.rpId || (await this.appSettings.getValue('webauthn_rp_id'))
)?.trim();
return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw);
}
async getAppConfig(authenticatedUser: User | undefined | null) {
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const userCount = await this.usersRepo.countNonGuest();
const isDemo = readEnv().demo.enabled;
const toggles = await this.resolveAuthToggles();
// One directory deeper than the legacy src/services location — the extra
@@ -279,36 +319,41 @@ export class AuthService {
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
// know whether the provider the admin selected actually has a credential.
const hasAmapKey = !!(await resolveApiKey(this.appSettings, this.usersRepo, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey)).key;
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
// AU5's 14 literal `app_settings` reads collapse into one `getValues` call
// (Task 5 brief ruling — the repository already has it, and the map is read
// the same way a missing/NULL-valued row was before: absent from the map).
const settings = await this.appSettings.getValues([
'places_provider', 'oidc_display_name', 'oidc_issuer', 'oidc_client_id', 'require_mfa',
'notification_channel', 'notify_trip_reminder', 'smtp_host', 'notification_channels',
'places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled',
'places_enrich_enabled', 'place_shadow_enabled',
]);
const placesProviderRow = settings.get('places_provider');
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcDisplayName = readEnv().oidc.displayName || settings.get('oidc_display_name') || null;
const oidcConfigured = !!(
(readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) &&
(readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value)
(readEnv().oidc.issuer || settings.get('oidc_issuer')) &&
(readEnv().oidc.clientId || settings.get('oidc_client_id'))
);
const requireMfaRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'require_mfa'");
const notifChannel = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channel'")?.value || 'none';
const tripReminderSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'")?.value;
const hasSmtpHost = !!(readEnv().smtp.host || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'smtp_host'")?.value);
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
const requireMfaValue = settings.get('require_mfa');
const notifChannel = settings.get('notification_channel') || 'none';
const tripReminderSetting = settings.get('notify_trip_reminder');
const hasSmtpHost = !!(readEnv().smtp.host || settings.get('smtp_host'));
const notifChannelsRaw = settings.get('notification_channels') || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
const placesAutocompleteSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'")?.value;
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
const placesDetailsSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_details_enabled'")?.value;
const placesDetailsEnabled = placesDetailsSetting !== 'false';
const placesEnrichSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'")?.value;
const placesEnrichEnabled = placesEnrichSetting !== 'false';
const placesPhotosEnabled = settings.get('places_photos_enabled') !== 'false';
const placesAutocompleteEnabled = settings.get('places_autocomplete_enabled') !== 'false';
const placesDetailsEnabled = settings.get('places_details_enabled') !== 'false';
const placesEnrichEnabled = settings.get('places_enrich_enabled') !== 'false';
// Fail-closed, and deliberately on this unauthenticated endpoint: whether an
// instance records what its users search for is something a visitor is
// entitled to know before logging in, not a detail to keep behind the door.
const placeShadowSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'")?.value;
const placeShadowEnabled = placeShadowSetting === 'true';
const setupComplete = userCount > 0 && !this.db.get("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1");
const placeShadowEnabled = settings.get('place_shadow_enabled') === 'true';
const setupComplete = userCount > 0 && (await this.usersRepo.findAdminNeedingPasswordChange()) === null;
return {
// Legacy fields (backward compat)
@@ -335,7 +380,7 @@ export class AuthService {
places_provider: placesProvider,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
require_mfa: requireMfaRow?.value === 'true',
require_mfa: requireMfaValue === 'true',
// The canonical live-read: same query + DEFAULT_ALLOWED_EXTENSIONS
// fallback the upload filters use, so the client's picker and the
// server's acceptance can never drift (the historical inline copy here
@@ -373,19 +418,19 @@ export class AuthService {
if (!readEnv().demo.enabled) {
return { error: 'Not found', status: 404 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE email = ?', DEMO_EMAIL_PRIMARY);
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
if (!user) return { error: 'Demo user not found', status: 500 };
const token = await this.generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
}
async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string }> {
const invite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', token) as any;
const invite = await this.inviteTokens.findByToken(token);
if (!invite) return { error: 'Invalid invite link', status: 404 };
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at ?? undefined };
}
async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
@@ -394,11 +439,11 @@ export class AuthService {
const email = typeof body.email === 'string' ? body.email.trim() : '';
const { password, invite_token } = body;
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const userCount = await this.usersRepo.countNonGuest();
let validInvite: any = null;
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
if (invite_token) {
validInvite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', invite_token);
validInvite = await this.inviteTokens.findByToken(invite_token);
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
@@ -423,8 +468,8 @@ export class AuthService {
}
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
const existingUser = this.db.get('SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) AND COALESCE(is_guest, 0) = 0', email, username);
if (existingUser) {
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
if (existingUserId !== null) {
return { error: 'Registration failed. Please try different credentials.', status: 409 };
}
@@ -436,33 +481,29 @@ export class AuthService {
// One transaction for the whole signup: a mid-sequence throw (invite
// bookkeeping, trip auto-join) must not leave a half-registered user.
return await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)',
username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'
);
const inserted = await this.usersRepo.insertUser({
username, email, password_hash, role, first_seen_version: readEnv().app.appVersion || '0.0.0',
});
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
const user = { id: inserted.id, username, email, role, avatar: null, mfa_enabled: false };
const token = await this.generateToken(user);
if (validInvite) {
const updated = this.db.get(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count',
validInvite.id
);
const updated = await this.inviteTokens.incrementUsedCount(validInvite.token);
if (!updated) {
console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`);
}
// Trip-bound invite (#1402): auto-add the freshly registered user to the
// trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted.
if (validInvite.trip_id) {
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(inserted.id), validInvite.created_by ?? null);
}
}
return {
token,
user: { ...user, avatar_url: null },
auditUserId: Number(result.lastInsertRowid),
auditUserId: Number(inserted.id),
auditDetails: { username, email, role },
};
});
@@ -496,7 +537,7 @@ export class AuthService {
// Guests (#1362) carry a synthetic email but must never authenticate — treat a
// matched guest row exactly like an unknown email (dummy-hash timing preserved).
const user = this.db.get<User>('SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0', email);
const user = await this.usersRepo.findByEmailCI(email);
// Always run bcrypt — even for unknown/OIDC-only users — so response time
// does not reveal whether the email exists in the database (CWE-203/208).
@@ -522,8 +563,8 @@ export class AuthService {
};
}
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
const pv = (user as User & { password_version?: number }).password_version ?? 0;
if (user.mfa_enabled === 1) {
const pv = user.password_version ?? 0;
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login', pv },
JWT_SECRET,
@@ -532,9 +573,9 @@ export class AuthService {
return { mfa_required: true, mfa_token };
}
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.touchLastLogin(user.id);
const token = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
token,
@@ -553,13 +594,18 @@ export class AuthService {
async getCurrentUser(
userId: number
): Promise<(Record<string, unknown> & Pick<User, 'id' | 'username' | 'email' | 'role'> & { avatar_url: string }) | null> {
const user = this.db.get<User>(
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?',
userId
);
const user = await this.usersRepo.findMeRow(userId);
if (!user) return null;
const base = stripUserForClient(user as User) as Record<string, unknown>;
return { ...base, id: user.id, username: user.username, email: user.email, role: user.role, avatar_url: avatarUrl(user) };
// `findMeRow`'s projection (id/username/email/role/avatar/oidc_issuer/
// created_at/mfa_enabled/must_change_password) is narrower than `User`
// and than `UserRow` — `toClientUser` expects the FULL row, so this one
// site keeps the same "trust me" cast the legacy generic-typed
// `this.db.get<User>(...)` read effectively was: `stripUserForClient`
// only destructures a handful of keys this projection never carried
// anyway (password_hash, the API-key columns, mfa_secret), so they're
// simply absent, exactly as before.
const base = stripUserForClient(user as unknown as User) as Record<string, unknown>;
return { ...base, id: user.id, username: user.username, email: user.email, role: user.role === 'admin' ? 'admin' : 'user', avatar_url: avatarUrl(user) };
}
// -------------------------------------------------------------------------
@@ -587,7 +633,7 @@ export class AuthService {
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const user = this.db.get<{ password_hash: string; password_version?: number }>('SELECT password_hash, password_version FROM users WHERE id = ?', userId);
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
}
@@ -596,14 +642,14 @@ export class AuthService {
const newPv = (user.password_version ?? 0) + 1;
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', hash, newPv, userId);
await this.usersRepo.setPassword(userId, hash, newPv);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
// token and OAuth bearer-token stores are pruned to match (same set the
// password-reset path already revokes).
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', userId);
await this.mcpTokens.deleteAllForUser(userId);
try {
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
await this.oauthTokens.revokeAllForUser(userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
});
@@ -622,7 +668,7 @@ export class AuthService {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
}
if (userRole === 'admin') {
const adminCount = this.db.get<{ count: number }>("SELECT COUNT(*) as count FROM users WHERE role = 'admin'")!.count;
const adminCount = await this.usersRepo.countAdmins();
if (adminCount <= 1) {
return { error: 'Cannot delete the last admin account', status: 400 };
}
@@ -647,13 +693,13 @@ export class AuthService {
// -------------------------------------------------------------------------
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key);
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value;
const value = await this.appSettings.getValue(key);
if (value !== null) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : value;
}
return { data: result };
}
@@ -671,15 +717,15 @@ export class AuthService {
managedKeys?: string[];
}> {
const body = rawBody as Record<string, unknown>;
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
const { require_mfa } = body;
if (require_mfa === true || require_mfa === 'true') {
const adminMfa = this.db.get<{ mfa_enabled: number }>('SELECT mfa_enabled FROM users WHERE id = ?', userId);
const adminMfa = await this.usersRepo.getMfaEnabled(userId);
// A user-verified passkey satisfies the MFA policy, so an admin who secured
// their own account with a passkey may enable it too (not only TOTP).
const adminHasPasskey = !!this.db.get('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1', userId);
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
return {
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
@@ -692,8 +738,8 @@ export class AuthService {
if (body.password_login !== undefined || body.oidc_login !== undefined) {
const current = await this.resolveAuthToggles();
const oidcConfigured = !!(
(readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) &&
(readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value)
(readEnv().oidc.issuer || (await this.appSettings.getValue('oidc_issuer'))) &&
(readEnv().oidc.clientId || (await this.appSettings.getValue('oidc_client_id')))
);
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
@@ -725,7 +771,7 @@ export class AuthService {
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
if (key === 'admin_ntfy_token' && val === '••••••••') continue;
if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val;
this.db.run("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", key, val);
await this.appSettings.setValue(key, val);
}
}
@@ -759,7 +805,7 @@ export class AuthService {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
const row = this.db.get<{ mfa_enabled: number }>('SELECT mfa_enabled FROM users WHERE id = ?', userId);
const row = await this.usersRepo.getMfaEnabled(userId);
if (row?.mfa_enabled) {
return { error: 'MFA is already enabled', status: 400 };
}
@@ -792,11 +838,7 @@ export class AuthService {
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
const enc = encryptMfaSecret(pending);
this.db.run('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
enc,
JSON.stringify(backupHashes),
userId
);
await this.usersRepo.enableMfa(userId, enc, JSON.stringify(backupHashes));
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: true, backup_codes: backupCodes };
}
@@ -810,15 +852,15 @@ export class AuthService {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
}
const policy = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'require_mfa'");
if (policy?.value === 'true') {
const policy = await this.appSettings.getValue('require_mfa');
if (policy === 'true') {
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
}
const { password, code } = body;
if (!password || !code) {
return { error: 'Password and authenticator code are required', status: 400 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', userId);
const user = await this.usersRepo.findById(userId);
if (!user?.mfa_enabled || !user.mfa_secret) {
return { error: 'MFA is not enabled', status: 400 };
}
@@ -831,9 +873,7 @@ export class AuthService {
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
}
this.db.run('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
userId
);
await this.usersRepo.disableMfa(userId);
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: false };
}
@@ -857,8 +897,8 @@ export class AuthService {
if (decoded.purpose !== 'mfa_login') {
return { error: 'Invalid verification token', status: 401 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', decoded.id);
if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) {
const user = await this.usersRepo.findById(decoded.id);
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
return { error: 'Invalid session', status: 401 };
}
const secret = decryptMfaSecret(user.mfa_secret);
@@ -876,17 +916,14 @@ export class AuthService {
// Consume the backup code and record the login atomically — the code
// must not burn without the login landing (or vice versa).
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
JSON.stringify(hashes),
user.id
);
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.setBackupCodesAndTouch(user.id, JSON.stringify(hashes));
await this.usersRepo.touchLastLogin(user.id);
});
} else {
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.touchLastLogin(user.id);
}
const sessionToken = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) },
@@ -935,10 +972,7 @@ export class AuthService {
}
// A guest (#1362) must never receive a reset link — treat its synthetic email as unknown.
const user = this.db.get<{ id: number; email: string; password_hash: string | null; oidc_sub: string | null }>(
'SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0',
email
);
const user = await this.usersRepo.findForPasswordReset(email);
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
@@ -955,19 +989,13 @@ export class AuthService {
// Invalidate any prior unconsumed tokens for this user so there is
// always at most one live reset link in flight.
this.db.run(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL",
user.id
);
await this.passwordResetTokens.consumeAllLiveForUser(user.id);
const raw = randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString('base64url');
const token_hash = hashResetToken(raw);
const expires_at = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
this.db.run(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)',
user.id, token_hash, expires_at, createdIp
);
await this.passwordResetTokens.insertToken({ user_id: user.id, token_hash, expires_at, created_ip: createdIp });
return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' };
}
@@ -993,10 +1021,7 @@ export class AuthService {
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
const tokenHash = hashResetToken(token);
const row = this.db.get<{ id: number; user_id: number; expires_at: string; consumed_at: string | null }>(
'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?',
tokenHash
);
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
@@ -1004,15 +1029,12 @@ export class AuthService {
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
}
const user = this.db.get<{ id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number }>(
'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?',
row.user_id
);
const user = await this.usersRepo.findResetTarget(row.user_id);
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
// MFA gate. If enabled, require a valid TOTP or backup code.
const mfaOn = user.mfa_enabled === 1 || user.mfa_enabled === true;
const mfaOn = user.mfa_enabled === 1;
let backupCodeConsumedIndex: number | null = null;
if (mfaOn) {
if (!user.mfa_secret) {
@@ -1037,33 +1059,24 @@ export class AuthService {
await this.uow.transactional(async () => {
// Burn the token first to keep it atomic with the password change.
this.db.run('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
await this.passwordResetTokens.markConsumed(row.id);
// Also burn every OTHER live token for this user — a fresh login
// should not leave a second door open.
this.db.run(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?",
user.id, row.id
);
this.db.run(
'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
newHash, newPv, user.id
);
await this.passwordResetTokens.consumeAllLiveForUser(user.id, row.id);
await this.usersRepo.setPassword(user.id, newHash, newPv);
// Consume backup code if one was used.
if (backupCodeConsumedIndex !== null) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
hashes.splice(backupCodeConsumedIndex, 1);
this.db.run('UPDATE users SET mfa_backup_codes = ? WHERE id = ?', JSON.stringify(hashes), user.id);
await this.usersRepo.setBackupCodes(user.id, JSON.stringify(hashes));
}
// Revoke every other credential class the user had. The
// password_version bump alone invalidates JWT cookie sessions, but
// MCP static tokens and OAuth 2.1 bearer tokens are separate stores
// that survive the bump unless we prune them here.
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', user.id);
await this.mcpTokens.deleteAllForUser(user.id);
try {
this.db.run(
"UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL",
user.id
);
await this.oauthTokens.revokeAllForUser(user.id);
} catch { /* oauth_tokens table may not exist in very old installs */ }
});
@@ -1083,8 +1096,8 @@ export class AuthService {
async isDemoUser(userId: number): Promise<boolean> {
if (!readEnv().demo.enabled) return false;
const user = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return isDemoEmail(user?.email);
const email = await this.usersRepo.getEmail(userId);
return isDemoEmail(email ?? undefined);
}
/**
+36 -8
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../database/database.service';
// Injected since BudgetModule dropped its AuthModule import (BudgetMcp's demo
// guard reads RuntimeEnvService + the users table now), which un-closed the
@@ -7,6 +8,8 @@ import { DatabaseService } from '../database/database.service';
import { BudgetService } from '../budget/budget.service';
import { pluginsDataRoot } from '../plugins/paths';
import { UnitOfWork } from '../database/unit-of-work';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
/**
* Account erasure — everything that has to happen around `DELETE FROM users`
@@ -26,6 +29,7 @@ export class UserCleanupService {
private readonly db: DatabaseService,
private readonly budget: BudgetService,
private readonly uow: UnitOfWork,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
) {}
/**
@@ -38,6 +42,13 @@ export class UserCleanupService {
*
* Best-effort per table so a slimmed-down schema (some tests) can't fail the user
* deletion itself.
*
* UC1–UC3 (`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`/
* `plugins`/`plugin_user_erasure_queue`) stay raw `DatabaseService` calls —
* every one of these tables is owned by `nest/plugins`, which lands in
* Plan 3j, not this plan (Plan 3b Task 5 ruling; inventory §6 "the single
* largest 'stays raw' carve-out in Plan 3b"). Only `DELETE FROM users`
* below (UC11) is this domain's own and converts.
*/
async erasePluginUserData(userId: number): Promise<void> {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
@@ -65,24 +76,41 @@ export class UserCleanupService {
} catch { /* plugins / queue table absent (slim schema) */ }
}
/**
* UC4–UC10: every table here is owned by a domain outside this plan —
* `trip_members` (`nest/trip-membership`, Plan 3c), `budget_items`
* (`nest/budget`, Plan 3e), `share_tokens`/`journey_share_tokens`/
* `journeys`/`journey_entries`/`journey_contributors` (`nest/share`/
* `nest/journey-share`/`nest/journey-domain`, Plan 3g) — so they stay raw
* `DatabaseService` calls (Plan 3b Task 5 ruling; inventory §6). Verbatim
* otherwise: same statement order, same bounded 3-table
* journey/journey_entries/journey_contributors sequence,
* `this.budget.removeUserFromBudgetItems` unchanged.
*/
private async cleanupUserReferences(userId: number): Promise<void> {
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId);
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId);
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId); // UC4 — Plan 3c
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId); // UC5 — Plan 3e
await this.budget.removeUserFromBudgetItems(userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId);
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId); // UC6 — Plan 3g
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId); // UC7 — Plan 3g
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
this.db.run('DELETE FROM journeys WHERE user_id = ?', userId);
this.db.run('DELETE FROM journeys WHERE user_id = ?', userId); // UC8 — Plan 3g
// Entries authored on other users' journeys (not covered by the cascade above)
this.db.run('DELETE FROM journey_entries WHERE author_id = ?', userId);
this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId);
this.db.run('DELETE FROM journey_entries WHERE author_id = ?', userId); // UC9 — Plan 3g
this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId); // UC10 — Plan 3g
}
/**
* UC11, the transaction's final statement — `UsersRepository.deleteById`
* (Plan 3b Task 5; the one pre-authorised `UsersRepository` addition, per
* `task-1-review.md`'s "contract gaps"). Everything above it (UC1–UC10)
* stays raw, per each method's own docstring.
*/
async deleteUserCompletely(userId: number): Promise<void> {
await this.uow.transactional(async () => {
await this.cleanupUserReferences(userId);
await this.erasePluginUserData(userId);
this.db.run('DELETE FROM users WHERE id = ?', userId);
await this.usersRepo.deleteById(userId);
});
}
}