From 45564b0a8ae1709a40e51c972f13a3cffda3cfb5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 1 Aug 2026 17:39:59 +0200 Subject: [PATCH] chore: move auth backend to proper DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the 1497-line services/authService.ts into the DI-native nest/auth domain (Wave-5 chain opener, unblocked by the atlas fold): - Pure crypto half (backup-code hash/match/generate, stripUserForClient, key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and avatarDir mkdir — documented parity exceptions) moves verbatim to the plain module nest/auth/auth.helpers.ts. - DB half folds into AuthService over DatabaseService + injected PermissionsService (ex permissions.bridge) + AtlasService (ex atlas.bridge, deleted on schedule). mfaSetupPending and the per-email reset throttle stay module-scoped so the bridge and container instances share them; avatarDir and require('../../../package.json') re-anchor one directory deeper — with the two injection swaps, the only non-verbatim lines. Controller-facing method surface unchanged. - New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts token verification, the journey/notifications/transports registrars' isDemoUser, and the still-legacy adminService/oidcService/passkeyService. - 15 domain *.mcp.ts demo guards now inject AuthService (their modules import AuthModule); atlas.mcp.ts alone stays on the bridge because AuthService injects AtlasService and the reverse module edge would close an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and PasskeyEnabledGuard inject AuthService too. - Tests move with case IDs preserved: authService.test.ts -> auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts (+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the previously untested getAppConfig/login/MFA/reset/admin-settings branches now under the src/nest coverage gate). auth.e2e converts DI-native (real bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an instance spy; websocket/integration suites repoint to bridge/helpers. Quirks preserved byte-for-byte (0-coord drop in getTravelStats, un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded revokeUserSessions in deleteMcpToken, current! assertions in updateApiKeys) — fixes land separately. --- migrate.md | 5 +- server/src/mcp/index.ts | 2 +- server/src/mcp/tools/journey.ts | 2 +- server/src/mcp/tools/notifications.ts | 2 +- server/src/mcp/tools/transports.ts | 2 +- server/src/nest/README.md | 39 +- .../src/nest/assignments/assignments.mcp.ts | 15 +- .../nest/assignments/assignments.module.ts | 3 +- server/src/nest/atlas/atlas-geo.ts | 2 +- server/src/nest/atlas/atlas.bridge.ts | 29 - server/src/nest/atlas/atlas.mcp.ts | 5 +- server/src/nest/atlas/atlas.service.ts | 2 +- server/src/nest/auth/auth.bridge.ts | 62 + server/src/nest/auth/auth.helpers.ts | 181 ++ server/src/nest/auth/auth.module.ts | 11 +- server/src/nest/auth/auth.service.ts | 1416 +++++++++++++++- server/src/nest/auth/passkey-enabled.guard.ts | 6 +- server/src/nest/budget/budget.mcp.ts | 21 +- server/src/nest/budget/budget.module.ts | 3 +- server/src/nest/collab/collab.mcp.ts | 24 +- server/src/nest/collab/collab.module.ts | 3 +- .../src/nest/collections/collections.mcp.ts | 5 +- .../nest/collections/collections.module.ts | 3 +- server/src/nest/days/day-notes.mcp.ts | 10 +- server/src/nest/days/days.mcp.ts | 17 +- server/src/nest/days/days.module.ts | 3 +- server/src/nest/oidc/oidc.module.ts | 2 + server/src/nest/oidc/oidc.service.ts | 6 +- server/src/nest/packing/packing.mcp.ts | 32 +- server/src/nest/packing/packing.module.ts | 3 +- server/src/nest/places/places.mcp.ts | 19 +- server/src/nest/places/places.module.ts | 3 +- .../src/nest/reservations/reservations.mcp.ts | 13 +- .../nest/reservations/reservations.module.ts | 3 +- server/src/nest/share/share.mcp.ts | 8 +- server/src/nest/share/share.module.ts | 3 +- server/src/nest/tags/tags.mcp.ts | 10 +- server/src/nest/tags/tags.module.ts | 2 + server/src/nest/todo/todo.mcp.ts | 16 +- server/src/nest/todo/todo.module.ts | 3 +- server/src/nest/transit/transit.mcp.ts | 5 +- server/src/nest/transit/transit.module.ts | 3 +- server/src/nest/trips/trips.mcp.ts | 15 +- server/src/nest/trips/trips.module.ts | 3 +- server/src/nest/vacay/vacay.mcp.ts | 42 +- server/src/nest/vacay/vacay.module.ts | 2 + server/src/services/adminService.ts | 2 +- server/src/services/authService.ts | 1497 ----------------- server/src/services/oidcService.ts | 2 +- server/src/services/passkeyService.ts | 2 +- server/tests/e2e/auth.e2e.test.ts | 141 +- server/tests/e2e/oidc.e2e.test.ts | 35 +- server/tests/helpers/mcp-test-controllers.ts | 32 +- server/tests/integration/auth.test.ts | 2 +- server/tests/unit/nest/atlas.service.test.ts | 27 +- server/tests/unit/nest/auth-guard.test.ts | 12 +- .../auth.helpers.test.ts} | 22 +- .../auth.service.test.ts} | 633 +++++-- server/tests/unit/nest/oidc.service.test.ts | 8 +- server/tests/websocket/connection.test.ts | 2 +- 60 files changed, 2551 insertions(+), 1932 deletions(-) delete mode 100644 server/src/nest/atlas/atlas.bridge.ts create mode 100644 server/src/nest/auth/auth.bridge.ts create mode 100644 server/src/nest/auth/auth.helpers.ts delete mode 100644 server/src/services/authService.ts rename server/tests/unit/{services/authService.test.ts => nest/auth.helpers.test.ts} (93%) rename server/tests/unit/{services/authServiceDb.test.ts => nest/auth.service.test.ts} (50%) diff --git a/migrate.md b/migrate.md index 98c5d17dc..8aef8a1e3 100644 --- a/migrate.md +++ b/migrate.md @@ -12,7 +12,7 @@ or the MCP layer gets a handle to the Nest app (app.get(TodoService)). Better to Wave 2 — cross-cutting leverage (parallel to wave 3, high payoff) -4. permissions (149 lines, zero deps, 17 Nest consumers) → an injectable PermissionsService (done 2026-07 — every module wrapper, the airtrail-import controller and PluginHostDepsFactory now inject it; the cache stays module-scoped so the DI singleton and permissions.bridge — kept for mcp/_shared.ts + adminService/authService/backupService/collectionsService — share one invalidation). Every module wrapper already calls checkPermission; this converts the single most-imported seam in one move. +4. permissions (149 lines, zero deps, 17 Nest consumers) → an injectable PermissionsService (done 2026-07 — every module wrapper, the airtrail-import controller and PluginHostDepsFactory now inject it; the cache stays module-scoped so the DI singleton and permissions.bridge — kept for mcp/_shared.ts + adminService/backupService — share one invalidation; the authService and collectionsService consumers fell away with their own folds). Every module wrapper already calls checkPermission; this converts the single most-imported seam in one move. 5. auditLog (171 lines, 13 Nest consumers) → AuditService (done 2026-07 — writeAudit is the injectable; getClientIp and the log*/LOG_LEVEL logger stay plain modules in nest/audit/ (client-ip.ts, audit-log.logger.ts with its frozen-at-import level + mkdir as a documented parity exception); audit.bridge covers mcp/index, oauthProvider and the legacy airtrail/immich/oauth services). 6. tripAccess (9 lines) — don't migrate, delete: it's a wrapper around canAccessTrip, which DatabaseService now exposes. Absorb its 11 call sites opportunistically. @@ -32,8 +32,7 @@ Wave 4 — the coupled cluster (order matters here) Wave 5 — the heavyweights, last -10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService (done 2026-08 — pulled forward as a zero-dependency frontier member opening the Wave-5 auth chain: the 1612-line core split places-style — the DB half (stats aggregation with its two divergent return shapes, visited countries/regions with the #1490 tombstones + cascade, bucket-list CRUD) folded into the wrapper `AtlasService` over `DatabaseService` alone, while the ~750-line pure-geo half (admin0/admin1 boundary stores with the #1576 streaming builders, poly/box indexes, Nominatim geocoding with its shared throttle, the geocode/region caches incl. the import-time unref'd cleanup interval, the `geocodingInFlight` dedup set) moved verbatim to the plain module `nest/atlas/atlas-geo.ts` so the multi-MB caches stay process-global across the container + bridge instances; the 10-tool registrar `mcp/tools/atlas.ts` **plus all four atlas resources in `mcp/resources.ts`** moved to the decorator-driven `atlas.mcp.ts` (the `when:` atlas-addon gate is parity here — legacy tools/resources gated, REST deliberately doesn't; `resources.test.ts` retired with its last two cases, moved to the atlas suites); the plugin RPC host swapped its 9 atlas imports for the injected service (23rd constructor dep — journeys is the factory's last legacy domain import); a minimal 2-export `atlas.bridge.ts` serves legacy `authService.getTravelStats` (the zero-bridge streak ends at five; the bridge dies when authService migrates — **authService is now unblocked**); the DTO ratchet cleared all three `AtlasController` allow-list entries)/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService (done 2026-08 — pulled forward as a frontier-ready member: the 1024-line saved-places core folded into the wrapper `CollectionsService` over DatabaseService + PermissionsService + RealtimeService (the `checkPermission` bridge import and `broadcastToUser` websocket import became injections; the `sendInvite` lazy notificationService `import()` stays, collab precedent; `deleteOldCollectionCover` re-anchored one directory deeper); the 25-tool registrar `mcp/tools/collections.ts` moved to the decorator-driven `collections.mcp.ts` — with NO `when:` addon gate, preserving the legacy registrar's addon-gate asymmetry — plus a new 23-case `tools-collections` characterization suite (the registrar was previously untested); the plugin RPC host swapped its 7 collections imports for the injected service (22nd constructor dep); **no bridge file anywhere**; the DTO ratchet cleared both collections allow-list entries (`reorder`, `delete-many`))/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needed no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files; placeService → transitItineraryService followed, both done 2026-07/08), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus - oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately +10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService (done 2026-08 — pulled forward as a zero-dependency frontier member opening the Wave-5 auth chain: the 1612-line core split places-style — the DB half (stats aggregation with its two divergent return shapes, visited countries/regions with the #1490 tombstones + cascade, bucket-list CRUD) folded into the wrapper `AtlasService` over `DatabaseService` alone, while the ~750-line pure-geo half (admin0/admin1 boundary stores with the #1576 streaming builders, poly/box indexes, Nominatim geocoding with its shared throttle, the geocode/region caches incl. the import-time unref'd cleanup interval, the `geocodingInFlight` dedup set) moved verbatim to the plain module `nest/atlas/atlas-geo.ts` so the multi-MB caches stay process-global across the container + bridge instances; the 10-tool registrar `mcp/tools/atlas.ts` **plus all four atlas resources in `mcp/resources.ts`** moved to the decorator-driven `atlas.mcp.ts` (the `when:` atlas-addon gate is parity here — legacy tools/resources gated, REST deliberately doesn't; `resources.test.ts` retired with its last two cases, moved to the atlas suites); the plugin RPC host swapped its 9 atlas imports for the injected service (23rd constructor dep — journeys is the factory's last legacy domain import); a minimal 2-export `atlas.bridge.ts` served legacy `authService.getTravelStats` (the zero-bridge streak ends at five; the bridge died on schedule with the 2026-08 auth fold); the DTO ratchet cleared all three `AtlasController` allow-list entries)/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService (done 2026-08 — pulled forward as a frontier-ready member: the 1024-line saved-places core folded into the wrapper `CollectionsService` over DatabaseService + PermissionsService + RealtimeService (the `checkPermission` bridge import and `broadcastToUser` websocket import became injections; the `sendInvite` lazy notificationService `import()` stays, collab precedent; `deleteOldCollectionCover` re-anchored one directory deeper); the 25-tool registrar `mcp/tools/collections.ts` moved to the decorator-driven `collections.mcp.ts` — with NO `when:` addon gate, preserving the legacy registrar's addon-gate asymmetry — plus a new 23-case `tools-collections` characterization suite (the registrar was previously untested); the plugin RPC host swapped its 7 collections imports for the injected service (22nd constructor dep); **no bridge file anywhere**; the DTO ratchet cleared both collections allow-list entries (`reorder`, `delete-many`))/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needed no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files; placeService → transitItineraryService followed, both done 2026-07/08), and the auth stack: authService (done 2026-08 — the 1497-line head of the auth → admin → oauth chain folded into the wrapper `AuthService` over DatabaseService + injected PermissionsService/AtlasService (retiring `atlas.bridge.ts` on schedule); the pure crypto half (backup-code hash/match/generate, `stripUserForClient`, key masking, the import-time `DUMMY_PASSWORD_HASH` timing equaliser + `avatarDir` mkdir, both documented parity exceptions) moved to the plain module `auth.helpers.ts`; `mfaSetupPending` + the per-email reset throttle (with its unref'd cleanup interval) stay module-scoped so the bridge and container instances share them; no MCP registrar existed and the plugin host never imported it, so neither surface changed — but the 15 in-container `*.mcp.ts` `isDemoUser` guards now inject `AuthService` (15 modules import AuthModule), with `atlas.mcp.ts` alone on the new 8-export `auth.bridge.ts` because the AuthModule→AtlasModule edge would otherwise close a cycle (places.mcp precedent); the bridge also serves `mcp/index.ts` token verification, the journey/notifications/transports registrars, and the still-legacy adminService/oidcService/passkeyService; tests moved with IDs preserved (authService.test.ts → auth.helpers.test.ts, authServiceDb.test.ts → auth.service.test.ts + AUTH-BR-001…007 bridge cases), auth.e2e went DI-native (real bcrypt, real audit rows), oidc.e2e switched its dead path-mock for an instance spy), leaving oauthService/oidcService/passkeyService — now unblocked repoint-consumers of the bridge — plus adminService next in the chain. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately outside the DI wrapper. Two special cases to keep in mind: airportService is lazily required by db/database.ts at boot (the flight-endpoint backfill) — whenever it migrates, that call should move into Nest's bootstrap instead, or you recreate the circular diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 4c9180c11..3e2203e6e 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'crypto'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; import { User } from '../types'; -import { verifyMcpToken, verifyJwtToken } from '../services/authService'; +import { verifyMcpToken, verifyJwtToken } from '../nest/auth/auth.bridge'; import { getUserByAccessToken } from '../services/oauthService'; import { isAddonEnabled } from '../nest/addons/addons.bridge'; import { ADDON_IDS } from '../addons'; diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts index 3196c98c4..e81397aec 100644 --- a/server/src/mcp/tools/journey.ts +++ b/server/src/mcp/tools/journey.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { isDemoUser } from '../../nest/auth/auth.bridge'; import { addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney, deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries, diff --git a/server/src/mcp/tools/notifications.ts b/server/src/mcp/tools/notifications.ts index a7332c133..a7d51b51d 100644 --- a/server/src/mcp/tools/notifications.ts +++ b/server/src/mcp/tools/notifications.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { isDemoUser } from '../../nest/auth/auth.bridge'; import { getNotifications, getUnreadCount, markRead as markNotificationRead, markUnread as markNotificationUnread, diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index e7c571ced..c94535490 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip } from '../../db/database'; -import { isDemoUser } from '../../services/authService'; +import { isDemoUser } from '../../nest/auth/auth.bridge'; import { createReservation, deleteReservation, getReservation, updateReservation, type EndpointInput, diff --git a/server/src/nest/README.md b/server/src/nest/README.md index c8e9d5d2a..8a2d907b3 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -29,8 +29,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day, permissions, audit, budget, trip, maps, - transit, place, transit-itinerary, collections — see the migration recipe - below. + transit, place, transit-itinerary, collections, atlas, auth — see the + migration recipe below. ## Cross-cutting Foundation pieces @@ -331,10 +331,10 @@ controller deliberately does not; the mark_region_visited / get_country_atlas_places no-uppercase divergence from REST relocated untouched. The plugin host swapped its 9 atlas imports for the injected service — its 23rd constructor dep — and a minimal 2-export `atlas.bridge.ts` -exists solely for the legacy `authService.getTravelStats` edge +existed solely for the legacy `authService.getTravelStats` edge (`getCountryFromCoords` re-exported straight from atlas-geo, -`getHiddenCountries` over the bridge instance); it dies when authService -migrates. resources.test.ts retired with its last two cases — every resource +`getHiddenCountries` over the bridge instance); it died with the auth fold, as +predicted. resources.test.ts retired with its last two cases — every resource it covered now lives in the domain suites. The trailing `fix(server)` quirk commit then wrapped the four multi-statement mark/unmark writes in `db.transaction()` (the region cascade nests as a savepoint), made the @@ -344,6 +344,35 @@ update, user-scoped the mutating bucket SQL, and uppercased the MCP region/country-places codes to match REST — each pinned by `ATLAS-SVC-031…036` plus two MCP casing cases; see migration-graph.md's "Quirks fixed after the atlas fold".) +authService followed (1497 lines, the biggest fold and the head of the +auth → admin → oauth chain: the pure crypto half — backup-code +hash/match/generate, `stripUserForClient`, key masking, the import-time +`DUMMY_PASSWORD_HASH` timing equaliser and the `avatarDir` mkdir (both +documented parity exceptions) — moved to the plain module `auth.helpers.ts`, +while the DB half — toggles/app-config, register/login with the CWE-208 +dummy-hash path, MFA setup/enable/disable/verify, the password-reset +throttle+token flows with their two `db.transaction()` revocation blocks, +API keys/settings, travel stats, MCP/ws/resource tokens, `isDemoUser` and +the two token verifiers — folded into `AuthService` over DatabaseService + +injected PermissionsService (ex `permissions.bridge`) and AtlasService (ex +`atlas.bridge`, which died on schedule). The `mfaSetupPending` and +per-email reset-throttle maps stay module-scoped (with the unref'd cleanup +interval) so the bridge instance and the container singleton share them; the +otplib `window: 1` mutation runs at module top; `require('../../../package.json')` +and `avatarDir` re-anchored one directory deeper — the only non-verbatim +lines besides the two injection swaps. No MCP registrar existed and the +plugin host never imported authService, so neither surface changed; the 15 +in-container `*.mcp.ts` demo guards now inject AuthService (their modules +import AuthModule) — except `atlas.mcp.ts`, which keeps a bridge import +because AuthService injects AtlasService and the reverse module edge would +close a cycle (places.mcp precedent). The 8-export `auth.bridge.ts` serves +`mcp/index.ts` token verification, the three legacy tool registrars' +`isDemoUser`, and the un-migrated adminService/oidcService/passkeyService. +Tests moved with IDs preserved: authService.test.ts → auth.helpers.test.ts, +authServiceDb.test.ts → auth.service.test.ts (+ AUTH-BR-001…007 bridge +delegation); auth.e2e converted DI-native — the 30-method whole-module mock +died, login now runs real bcrypt and real audit rows; oidc.e2e switched its +dead path-mock for a `vi.spyOn(app.get(AuthService), …)` instance stub.) Repeat these steps per service (next up: **the notifications fan-in** — per the dependency-honest order in diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 7f52c27da..4329b955b 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { AssignmentsService } from './assignments.service'; import { DaysService } from '../days/days.service'; @@ -24,6 +24,7 @@ export class AssignmentsMcp { constructor( private readonly assignments: AssignmentsService, private readonly days: DaysService, + private readonly auth: AuthService, ) {} @Tool({ @@ -42,7 +43,7 @@ export class AssignmentsMcp { { tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.'); @@ -68,7 +69,7 @@ export class AssignmentsMcp { { tripId, dayId, assignmentId }: { tripId: number; dayId: number; assignmentId: number }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId)) @@ -97,7 +98,7 @@ export class AssignmentsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId); @@ -131,7 +132,7 @@ export class AssignmentsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -180,7 +181,7 @@ export class AssignmentsMcp { { tripId, assignmentId, userIds }: { tripId: number; assignmentId: number; userIds: number[] }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -204,7 +205,7 @@ export class AssignmentsMcp { { tripId, dayId, assignmentIds }: { tripId: number; dayId: number; assignmentIds: number[] }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); diff --git a/server/src/nest/assignments/assignments.module.ts b/server/src/nest/assignments/assignments.module.ts index 604a75ba2..bdb0eb936 100644 --- a/server/src/nest/assignments/assignments.module.ts +++ b/server/src/nest/assignments/assignments.module.ts @@ -4,6 +4,7 @@ import { PermissionsModule } from '../permissions/permissions.module'; import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller'; import { AssignmentsService } from './assignments.service'; import { AssignmentsMcp } from './assignments.mcp'; +import { AuthModule } from '../auth/auth.module'; /** * Assignments domain (S7 — Phase 2 trip sub-domain). The day-assignments mount @@ -13,7 +14,7 @@ import { AssignmentsMcp } from './assignments.mcp'; */ @Module({ // DaysModule: AssignmentsMcp injects DaysService for the target-day checks. - imports: [DaysModule, PermissionsModule], + imports: [DaysModule, PermissionsModule, AuthModule], controllers: [DayAssignmentsController, AssignmentOpsController], providers: [AssignmentsService, AssignmentsMcp], exports: [AssignmentsService], diff --git a/server/src/nest/atlas/atlas-geo.ts b/server/src/nest/atlas/atlas-geo.ts index 2b9aaf8d2..ade89d6d6 100644 --- a/server/src/nest/atlas/atlas-geo.ts +++ b/server/src/nest/atlas/atlas-geo.ts @@ -11,7 +11,7 @@ import zlib from 'zlib'; // per-country store, geocode/region caches, country poly/box indexes, the // shared Nominatim throttle and the geocodingInFlight dedup set) are // file-scoped so their lifetime stays process-global no matter how many -// AtlasService instances exist (container singleton + atlas.bridge.ts + +// AtlasService instances exist (container singleton + auth.bridge.ts + // test helpers) — instance state would duplicate the multi-MB indexes the // #1576 OOM fix exists to avoid and split the ≥1.1s Nominatim throttle. // Relocated verbatim from the legacy services/atlasService.ts fold. diff --git a/server/src/nest/atlas/atlas.bridge.ts b/server/src/nest/atlas/atlas.bridge.ts deleted file mode 100644 index 6d30f91f0..000000000 --- a/server/src/nest/atlas/atlas.bridge.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { db } from '../../db/database'; -import { DatabaseService } from '../database/database.service'; -import { AtlasService } from './atlas.service'; - -/** - * Non-Nest entry point for the atlas domain — for code running OUTSIDE the - * Nest container. Sole consumer today: the legacy services/authService - * (getTravelStats resolves reservation endpoints to countries and subtracts - * the user's hidden ones). The atlas MCP tools and resources moved to the - * DI-discovered atlas.mcp.ts, and the plugin RPC host injects AtlasService, - * so neither needs this. Exports only the legacy services/atlasService names - * authService still consumes, 1:1, so the repoint is an import-path-only - * diff. Inside the container, inject AtlasService instead. Delete this file - * when authService migrates. - * - * getCountryFromCoords is pure geo (no DB) and re-exported straight from the - * atlas-geo helper module; its poly/box indexes are module-scoped there, so - * this entry point and the container share one copy (#1576). - * - * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto - * the shared better-sqlite3 singleton (same pattern as permissions.bridge.ts). - */ -const atlas = new AtlasService(new DatabaseService(db)); - -export { getCountryFromCoords } from './atlas-geo'; - -export function getHiddenCountries(userId: number): Set { - return atlas.getHiddenCountries(userId); -} diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index 9aeb689e1..ad7134c17 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -5,7 +5,10 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +// auth.bridge, not an injected AuthService: AuthService injects AtlasService +// (getTravelStats), so AtlasModule importing AuthModule would close a module +// cycle — same documented trade-off as places.mcp.ts keeping assignments.bridge. +import { isDemoUser } from '../auth/auth.bridge'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { AtlasService } from './atlas.service'; diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index ccefeeb90..c98aa80a3 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -44,7 +44,7 @@ function countryCodeFromRegionCode(regionCode: string): string { * The pure-geo machinery (bundled admin0/admin1 boundaries, point-in-polygon * indexes, Nominatim geocoding and its caches) lives in ./atlas-geo — a plain * module so the multi-MB caches stay process-global across the container - * instance, the atlas.bridge.ts instance, and test helpers (#1576). + * instance, the auth.bridge.ts instance, and test helpers (#1576). * Returns native service shapes; the client-facing contracts live in * @trek/shared. No broadcasts: atlas rows are uid-scoped, not trip-scoped. */ diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts new file mode 100644 index 000000000..1f48a4c59 --- /dev/null +++ b/server/src/nest/auth/auth.bridge.ts @@ -0,0 +1,62 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import { AtlasService } from '../atlas/atlas.service'; +import { AuthService } from './auth.service'; +import { User } from '../../types'; + +/** + * Non-Nest entry point for the auth domain — for code running OUTSIDE the + * Nest container: the legacy MCP transport (mcp/index.ts token verification), + * the legacy tool registrars (mcp/tools/journey|notifications|transports), the + * not-yet-migrated services/adminService, services/oidcService and + * services/passkeyService, and — as the one in-container exception — + * atlas.mcp.ts, which cannot inject AuthService without closing an + * AuthModule↔AtlasModule cycle (AuthService injects AtlasService for + * getTravelStats; same documented trade-off as places.mcp.ts keeping its + * assignments.bridge imports). + * + * Exports the legacy services/authService names 1:1 so repointing a consumer + * is an import-path-only diff. Inside the container, inject AuthService + * instead. Delete exports as their last legacy consumer migrates. + * + * stripUserForClient is pure and re-exported straight from auth.helpers.ts; + * avatarUrl keeps the legacy re-export shape (services/avatarUrl). The + * pending-MFA and reset-throttle maps are module-scoped in auth.service.ts, + * so this instance and the container singleton share one copy. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton (same pattern as permissions.bridge.ts). + */ +const auth = new AuthService( + new DatabaseService(db), + new PermissionsService(new DatabaseService(db)), + new AtlasService(new DatabaseService(db)), +); + +export { stripUserForClient } from './auth.helpers'; +export { avatarUrl } from '../../services/avatarUrl'; + +export function isDemoUser(userId: number): boolean { + return auth.isDemoUser(userId); +} + +export function verifyMcpToken(rawToken: string): User | null { + return auth.verifyMcpToken(rawToken); +} + +export function verifyJwtToken(token: string): User | null { + return auth.verifyJwtToken(token); +} + +export function resolveAuthToggles() { + return auth.resolveAuthToggles(); +} + +export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) { + return auth.generateToken(user, rememberMe); +} + +export function createWsToken(userId: number) { + return auth.createWsToken(userId); +} diff --git a/server/src/nest/auth/auth.helpers.ts b/server/src/nest/auth/auth.helpers.ts new file mode 100644 index 000000000..43cb5911f --- /dev/null +++ b/server/src/nest/auth/auth.helpers.ts @@ -0,0 +1,181 @@ +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import { User } from '../../types'; +import { decrypt_api_key } from '../../services/apiKeyCrypto'; + +/** + * Pure half of the auth domain (no DB, no injected deps) — the DB half lives + * in auth.service.ts. Split out so the legacy consumers (passkeyService, the + * moved unit tests) and both the container singleton and the bridge instance + * share one copy, mirroring atlas-geo.ts / maps.helpers.ts. + * + * Everything here moved verbatim from src/services/authService.ts. + */ + +// bcrypt cost factor for user passwords. Shared by register/changePassword/ +// resetPassword and the dummy-hash timing equaliser below — must stay in sync. +export const BCRYPT_COST = 12; + +// Shape check for email input on register and profile update. +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Pre-computed bcrypt hash to equalise timing of "unknown email" and +// "OIDC-only account" branches with the real verification path (CWE-208). +// Import-time bcrypt on purpose (legacy parity): computing it lazily would +// make the first unknown-email login measurably slower than the rest. +export const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', BCRYPT_COST); + +export const MFA_BACKUP_CODE_COUNT = 10; + +export const ADMIN_SETTINGS_KEYS = [ + 'allow_registration', 'allowed_file_types', 'require_mfa', + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', + 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', + 'notify_trip_reminder', + 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', + 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', +]; + +// One directory deeper than the legacy src/services location, so the extra +// '../' keeps resolving to server/uploads/avatars (same anchor the multer +// diskStorage in auth.controller.ts uses). Import-time mkdir on purpose +// (legacy parity): the upload dir must exist before the first request. +export const avatarDir = path.join(__dirname, '../../../uploads/avatars'); +if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); + +export const KNOWN_COUNTRIES = new Set([ + 'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien', + 'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien', + 'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien', + 'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', + 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', + 'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen', + 'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien', + 'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien', + 'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland', + 'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen', + 'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia', + 'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru', + 'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate', + 'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong', + 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', + 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', + 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', + 'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien', + 'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei', + 'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait', + 'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien', + 'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika', + 'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch', + 'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland', +]); + +// --------------------------------------------------------------------------- +// Helpers (exported for route-level use where needed) +// --------------------------------------------------------------------------- + +export function utcSuffix(ts: string | null | undefined): string | null { + if (!ts) return null; + return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; +} + +export function stripUserForClient(user: User): Record { + const { + password_hash: _p, + maps_api_key: _m, + openweather_api_key: _o, + unsplash_api_key: _u, + mfa_secret: _mf, + mfa_backup_codes: _mbc, + ...rest + } = user; + return { + ...rest, + created_at: utcSuffix(rest.created_at), + updated_at: utcSuffix(rest.updated_at), + last_login: utcSuffix(rest.last_login), + mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), + must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true), + }; +} + +export function maskKey(key: string | null | undefined): string | null { + if (!key) return null; + if (key.length <= 8) return '--------'; + return '----' + key.slice(-4); +} + +export function mask_stored_api_key(key: string | null | undefined): string | null { + const plain = decrypt_api_key(key); + return maskKey(plain); +} + +// --------------------------------------------------------------------------- +// MFA helpers +// --------------------------------------------------------------------------- + +export function normalizeBackupCode(input: string): string { + return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +} + +// Legacy SHA-256 hex hash. Kept so existing stored hashes (from before +// the bcrypt migration) can still be verified in `matchBackupCode` +// without forcing every user to re-enrol their MFA device. New hashes +// are produced by `hashBackupCodeBcrypt` below. +export function hashBackupCode(input: string): string { + return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex'); +} + +const BCRYPT_BACKUP_COST = 10; + +/** + * Hash a backup code with bcrypt for at-rest storage. Backup codes only + * have ~40 bits of entropy (8 hex chars) so a plain SHA-256 rainbow + * table cracks them in minutes if the DB ever leaks. bcrypt with a + * moderate cost raises that cost by ~3-4 orders of magnitude. + */ +export function hashBackupCodeBcrypt(input: string): string { + return bcrypt.hashSync(normalizeBackupCode(input), BCRYPT_BACKUP_COST); +} + +/** + * Constant-time match of a plaintext backup code against a stored hash + * in either format (bcrypt or legacy SHA-256 hex). Used by login and + * password-reset flows; callers that need to CONSUME the matching + * entry should use this to find the index, then splice it out. + */ +export function matchBackupCode(plaintext: string, storedHash: string): boolean { + if (!storedHash) return false; + if (storedHash.startsWith('$2')) { + // bcrypt hash — compareSync is constant-time internally. + try { return bcrypt.compareSync(normalizeBackupCode(plaintext), storedHash); } + catch { return false; } + } + // Legacy SHA-256 hex. Compare the SHA-256 of the input against the + // stored hex with a constant-time comparator so timing can't leak. + const candidate = hashBackupCode(plaintext); + if (candidate.length !== storedHash.length) return false; + return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(storedHash)); +} + +export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] { + const codes: string[] = []; + while (codes.length < count) { + const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + if (!codes.includes(code)) codes.push(code); + } + return codes; +} + +export function parseBackupCodeHashes(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; + } catch { + return []; + } +} diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index fcf994a09..fe48899fa 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -5,16 +5,25 @@ import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { RateLimitService } from './rate-limit.service'; import { AuditModule } from '../audit/audit.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { AtlasModule } from '../atlas/atlas.module'; /** * Auth module — public flows (login/register/reset/mfa-verify/logout) and the * authenticated account/MFA/token endpoints. The OIDC sub-mount (/api/auth/oidc) * is a separate, not-yet-migrated route, so the strangler lists the auth * sub-paths explicitly rather than claiming all of /api/auth. + * + * PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds + * getTravelStats' hidden-countries subtraction. AuthService is exported for + * the in-container consumers (the domain *.mcp.ts demo guards, OidcService, + * PasskeyEnabledGuard); everything outside the container goes through + * auth.bridge.ts. */ @Module({ - imports: [AuditModule], + imports: [AuditModule, PermissionsModule, AtlasModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, RateLimitService], + exports: [AuthService], }) export class AuthModule {} diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index aa954620b..daf3bfddb 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -1,19 +1,131 @@ import { Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import path from 'path'; +import fs from 'fs'; +import { authenticator } from 'otplib'; +import QRCode from 'qrcode'; +import { randomBytes, createHash } from 'crypto'; import type { Request, Response } from 'express'; -import * as auth from '../../services/authService'; +import { readEnv } from '../../app-config'; +import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config'; +import { DatabaseService } from '../database/database.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import { AtlasService } from '../atlas/atlas.service'; +import { getCountryFromCoords } from '../atlas/atlas-geo'; +import { validatePassword } from '../../services/passwordPolicy'; +import { encryptMfaSecret, decryptMfaSecret } from '../../services/mfaCrypto'; +import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../../services/apiKeyCrypto'; +import { createEphemeralToken } from '../../services/ephemeralTokens'; +// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls +// the whole tools fan-out (and via the domain bridges, the Nest services) into +// every consumer of this module — a nest→mcp→nest module cycle. +import { revokeUserSessions } from '../../mcp/sessionManager'; +import { startTripReminders } from '../../scheduler'; +import { deleteUserCompletely } from '../../services/userCleanupService'; +import { emitUserDeleted } from '../../plugin-user-lifecycle'; +import { getFlightDistanceKm } from '../../services/distanceService'; +import { verifyJwtAndLoadUser } from '../../middleware/auth'; +import { User } from '../../types'; +import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../../services/demo'; +import { avatarUrl } from '../../services/avatarUrl'; +import { joinTripAsMember } from '../../services/tripMembership'; +import { isPasskeyConfigured } from '../../services/webauthnConfig'; import { setAuthCookie, clearAuthCookie } from '../../services/cookie'; import { sendPasswordResetEmail } from '../../services/notifications'; import { getAppUrl } from '../../app-config'; -import type { User } from '../../types'; +import { + ADMIN_SETTINGS_KEYS, + BCRYPT_COST, + DUMMY_PASSWORD_HASH, + EMAIL_REGEX, + KNOWN_COUNTRIES, + avatarDir, + generateBackupCodes, + hashBackupCodeBcrypt, + mask_stored_api_key, + matchBackupCode, + parseBackupCodeHashes, + stripUserForClient, +} from './auth.helpers'; + +// Mutates otplib module state; must run before any TOTP verify in either the +// container singleton or the bridge instance (legacy parity — same line sat at +// the top of services/authService.ts). +authenticator.options = { window: 1 }; + +const MFA_SETUP_TTL_MS = 15 * 60 * 1000; +// Module-scoped on purpose: the bridge instance and the container singleton +// must see the same pending-MFA state (permissions-cache precedent). +const mfaSetupPending = new Map(); + +// 60 min; long enough to read the email in a second tab, short enough +// that a leaked link is unlikely to still be valid when someone tries it. +const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000; +const PASSWORD_RESET_TOKEN_BYTES = 32; // 256-bit entropy /** - * Thin Nest wrapper around the existing auth service. Token generation, the - * password/MFA/backup-code crypto, the JWT cookie set/clear and the reset-email - * delivery all reuse the legacy code unchanged. Access control + audit stay in - * the controller (mirroring the legacy route handlers). + * Returns the SHA-256 hex hash of a reset token. Raw tokens are never + * persisted — we only store and compare their hashes. + */ +function hashResetToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** + * Shape returned by requestPasswordReset. For enumeration-safety the + * route ALWAYS returns the same response to the client regardless of + * whether a user existed — this struct is only consumed internally by + * the route handler to decide whether to send an email / log a link. + */ +export interface PasswordResetRequestOutcome { + tokenForDelivery: string | null; // raw token — send via email or log, never return to client + userId: number | null; + userEmail: string | null; + reason: 'issued' | 'no_user' | 'oidc_only' | 'throttled_per_email' | 'password_login_disabled'; +} + +// Per-email throttle (defence-in-depth on top of the per-IP limiter). +// Module-scoped + import-time interval on purpose (legacy parity, shared +// between bridge and container instances — atlas-geo interval precedent). +const perEmailResetAttempts = new Map(); +const PASSWORD_RESET_PER_EMAIL_WINDOW_MS = 15 * 60 * 1000; +const PASSWORD_RESET_PER_EMAIL_MAX = 3; +setInterval(() => { + const now = Date.now(); + for (const [key, record] of perEmailResetAttempts) { + if (now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) perEmailResetAttempts.delete(key); + } +}, 5 * 60 * 1000).unref?.(); + +export interface ResetPasswordOutcome { + error?: string; + status?: number; + success?: boolean; + /** When true the client must collect a TOTP/backup code and call again. */ + mfa_required?: boolean; + userId?: number; +} + +/** + * 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 + * and AtlasService are injected (they replaced the permissions.bridge and + * atlas.bridge imports); 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. */ @Injectable() export class AuthService { + constructor( + private readonly db: DatabaseService, + private readonly permissions: PermissionsService, + private readonly atlas: AtlasService, + ) {} + // Cookie setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); } clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); } @@ -22,41 +134,1267 @@ export class AuthService { getAppUrl() { return getAppUrl(); } sendPasswordResetEmail(email: string, url: string, userId: number | null) { return sendPasswordResetEmail(email, url, userId); } - // Public config + auth flows - getAppConfig(user: User | undefined) { return auth.getAppConfig(user); } - demoLogin() { return auth.demoLogin(); } - validateInviteToken(token: string) { return auth.validateInviteToken(token); } - registerUser(body: unknown) { return auth.registerUser(body as Parameters[0]); } - loginUser(body: unknown) { return auth.loginUser(body as Parameters[0]); } - requestPasswordReset(email: string, ip: string) { return auth.requestPasswordReset(email, ip); } - resetPassword(body: unknown) { return auth.resetPassword(body as Parameters[0]); } - verifyMfaLogin(body: unknown) { return auth.verifyMfaLogin(body as Parameters[0]); } + // ------------------------------------------------------------------------- + // Toggles + tokens + // ------------------------------------------------------------------------- - // Account - getCurrentUser(userId: number) { return auth.getCurrentUser(userId); } - changePassword(userId: number, email: string, body: unknown) { return auth.changePassword(userId, email, body as Parameters[2]); } - deleteAccount(userId: number, email: string, role: string) { return auth.deleteAccount(userId, email, role); } - updateMapsKey(userId: number, key: unknown) { return auth.updateMapsKey(userId, key as string); } - updateApiKeys(userId: number, body: unknown) { return auth.updateApiKeys(userId, body as Parameters[1]); } - updateSettings(userId: number, body: unknown) { return auth.updateSettings(userId, body as Parameters[1]); } - getSettings(userId: number) { return auth.getSettings(userId); } - saveAvatar(userId: number, filename: string) { return auth.saveAvatar(userId, filename); } - deleteAvatar(userId: number) { return auth.deleteAvatar(userId); } - listUsers(userId: number) { return auth.listUsers(userId); } - validateKeys(userId: number) { return auth.validateKeys(userId); } - getAppSettings(userId: number) { return auth.getAppSettings(userId); } - updateAppSettings(userId: number, body: unknown) { return auth.updateAppSettings(userId, body as Parameters[1]); } - getTravelStats(userId: number) { return auth.getTravelStats(userId); } + resolveAuthToggles(): { + password_login: boolean; + password_registration: boolean; + oidc_login: boolean; + 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; + // 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 hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration'] + .some(k => get(k) !== 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', + passkey_login, + }; + if (readEnv().oidc.only) { + result.password_login = false; + result.password_registration = false; + } + return result; + } + + // Legacy fallback + const oidcOnlyEnabled = readEnv().oidc.only || get('oidc_only') === 'true'; + const oidcConfigured = !!( + (readEnv().oidc.issuer || get('oidc_issuer')) && + (readEnv().oidc.clientId || get('oidc_client_id')) + ); + const oidcOnly = oidcOnlyEnabled && oidcConfigured; + const allowReg = (get('allow_registration') ?? 'true') === 'true'; + + return { + password_login: !oidcOnly, + password_registration: !oidcOnly && allowReg, + oidc_login: true, + oidc_registration: allowReg, + passkey_login, + }; + } + + isOidcOnlyMode(): boolean { + return !this.resolveAuthToggles().password_login; + } + + generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) { + 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); + // "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 = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS; + return jwt.sign( + { id: user.id, pv }, + JWT_SECRET, + { expiresIn, algorithm: 'HS256' } + ); + } + + getPendingMfaSecret(userId: number): string | null { + const row = mfaSetupPending.get(userId); + if (!row || Date.now() > row.exp) { + mfaSetupPending.delete(userId); + return null; + } + return row.secret; + } + + // ------------------------------------------------------------------------- + // App config (public) + // ------------------------------------------------------------------------- + + 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 isDemo = readEnv().demo.enabled; + const toggles = this.resolveAuthToggles(); + // One directory deeper than the legacy src/services location — the extra + // '../' keeps resolving to the workspace package.json. + const version: string = readEnv().app.appVersion ?? require('../../../package.json').version; + const hasGoogleKey = !!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1"); + const oidcDisplayName = readEnv().oidc.displayName || + this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || 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) + ); + 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 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 setupComplete = userCount > 0 && !this.db.get("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1"); + + return { + // Legacy fields (backward compat) + allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration), + oidc_only_mode: !toggles.password_login && !toggles.password_registration, + // Granular toggles + password_login: toggles.password_login, + password_registration: isDemo ? false : toggles.password_registration, + oidc_login: toggles.oidc_login, + oidc_registration: isDemo ? false : toggles.oidc_registration, + // Passkey login: the instance toggle + whether a usable RP ID resolves for + // this deployment. The login page shows the passkey button only when both + // are true. `passkey_configured` stays a pure boolean — it never leaks the + // resolved RP ID / origin / APP_URL on this unauthenticated endpoint. + passkey_login: toggles.passkey_login, + passkey_configured: isPasskeyConfigured(), + env_override_oidc_only: readEnv().oidc.only, + has_users: userCount > 0, + setup_complete: setupComplete, + version, + is_prerelease: version.includes('-pre.'), + has_maps_key: hasGoogleKey, + oidc_configured: oidcConfigured, + oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, + require_mfa: requireMfaRow?.value === 'true', + allowed_file_types: this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'allowed_file_types'")?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', + demo_mode: isDemo, + demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined, + demo_password: isDemo ? 'demo12345' : undefined, + timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + notification_channel: notifChannel, + notification_channels: activeChannels, + available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, + trip_reminders_enabled: tripRemindersEnabled, + places_photos_enabled: placesPhotosEnabled, + places_autocomplete_enabled: placesAutocompleteEnabled, + places_details_enabled: placesDetailsEnabled, + permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined, + // Case-sensitive on purpose (legacy parity). + dev_mode: readEnv().app.nodeEnv === 'development', + }; + } + + // ------------------------------------------------------------------------- + // Auth: register, login, demo + // ------------------------------------------------------------------------- + + demoLogin(): { error?: string; status?: number; token?: string; user?: Record } { + if (!readEnv().demo.enabled) { + return { error: 'Not found', status: 404 }; + } + const user = this.db.get('SELECT * FROM users WHERE email = ?', DEMO_EMAIL_PRIMARY); + if (!user) return { error: 'Demo user not found', status: 500 }; + const token = this.generateToken(user); + const safe = stripUserForClient(user) as Record; + return { token, user: { ...safe, avatar_url: avatarUrl(user) } }; + } + + validateInviteToken(token: string): { 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; + 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 }; + } + + registerUser(rawBody: unknown): { error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record } { + const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string }; + const username = typeof body.username === 'string' ? body.username.trim() : ''; + 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; + + let validInvite: any = null; + if (invite_token) { + validInvite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', 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 }; + } + + if (userCount > 0 && !validInvite) { + const toggles = this.resolveAuthToggles(); + if (!toggles.password_registration) { + return { error: 'Password registration is disabled. Contact your administrator.', status: 403 }; + } + } + + if (!username || !email || !password) { + return { error: 'Username, email and password are required', status: 400 }; + } + + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; + + if (!EMAIL_REGEX.test(email)) { + return { error: 'Invalid email format', status: 400 }; + } + + // 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) { + return { error: 'Registration failed. Please try different credentials.', status: 409 }; + } + + const password_hash = bcrypt.hashSync(password, BCRYPT_COST); + const isFirstUser = userCount === 0; + const role = isFirstUser ? 'admin' : 'user'; + + try { + 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 user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; + const token = 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 + ); + 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) { + joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null); + } + } + + return { + token, + user: { ...user, avatar_url: null }, + auditUserId: Number(result.lastInsertRowid), + auditDetails: { username, email, role }, + }; + } catch { + return { error: 'Error creating user', status: 500 }; + } + } + + loginUser(rawBody: unknown): { + error?: string; + status?: number; + token?: string; + user?: Record; + mfa_required?: boolean; + mfa_token?: string; + remember?: boolean; + auditUserId?: number | null; + auditAction?: string; + auditDetails?: Record; + } { + const body = rawBody as { email?: string; password?: string; remember_me?: boolean }; + if (this.isOidcOnlyMode()) { + return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; + } + + const { email, password, remember_me } = body; + const remember = remember_me === true; + if (!email || !password) { + return { error: 'Email and password are required', status: 400 }; + } + + // 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('SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0', 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). + const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH; + const validPassword = bcrypt.compareSync(password, hashToCheck); + + if (!user) { + return { + error: 'Invalid email or password', status: 401, + auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' }, + }; + } + if (!user.password_hash) { + return { + error: 'Invalid email or password', status: 401, + auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' }, + }; + } + if (!validPassword) { + return { + error: 'Invalid email or password', status: 401, + auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'wrong_password' }, + }; + } + + if (user.mfa_enabled === 1 || user.mfa_enabled === true) { + const pv = (user as User & { password_version?: number }).password_version ?? 0; + const mfa_token = jwt.sign( + { id: Number(user.id), purpose: 'mfa_login', pv }, + JWT_SECRET, + { expiresIn: '5m', algorithm: 'HS256' } + ); + 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); + const token = this.generateToken(user, remember); + const userSafe = stripUserForClient(user) as Record; + + return { + token, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + remember, + auditUserId: Number(user.id), + auditAction: 'user.login', + auditDetails: { email }, + }; + } + + // ------------------------------------------------------------------------- + // Session + // ------------------------------------------------------------------------- + + getCurrentUser( + userId: number + ): (Record & Pick & { avatar_url: string }) | null { + const user = this.db.get( + 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?', + userId + ); + if (!user) return null; + const base = stripUserForClient(user as User) as Record; + return { ...base, id: user.id, username: user.username, email: user.email, role: user.role, avatar_url: avatarUrl(user) }; + } + + // ------------------------------------------------------------------------- + // Password & account + // ------------------------------------------------------------------------- + + changePassword( + userId: number, + userEmail: string, + rawBody: unknown + ): { error?: string; status?: number; success?: boolean; token?: string } { + const body = rawBody as { current_password?: string; new_password?: string }; + if (this.isOidcOnlyMode()) { + return { error: 'Password authentication is disabled.', status: 403 }; + } + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { + return { error: 'Password change is disabled in demo mode.', status: 403 }; + } + + const { current_password, new_password } = body; + if (!current_password) return { error: 'Current password is required', status: 400 }; + if (!new_password) return { error: 'New password is required', status: 400 }; + + 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); + if (!user || !bcrypt.compareSync(current_password, user.password_hash)) { + return { error: 'Current password is incorrect', status: 401 }; + } + + const hash = bcrypt.hashSync(new_password, BCRYPT_COST); + const newPv = (user.password_version ?? 0) + 1; + + this.db.transaction(() => { + this.db.run('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', hash, newPv, userId); + // 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); + try { + this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId); + } catch { /* oauth_tokens table may not exist in very old installs */ } + }); + + try { revokeUserSessions?.(userId); } catch { /* best-effort */ } + + // Re-issue a session bound to the new password_version so the current device + // stays logged in while other existing sessions are rotated out by the pv gate. + const token = this.generateToken({ id: userId, password_version: newPv }); + return { success: true, token }; + } + + deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } { + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { + 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; + if (adminCount <= 1) { + return { error: 'Cannot delete the last admin account', status: 400 }; + } + } + deleteUserCompletely(userId); + emitUserDeleted(userId); // let plugins erase their own per-user data + return { success: true }; + } + + // ------------------------------------------------------------------------- + // API keys + // ------------------------------------------------------------------------- + + updateMapsKey(userId: number, key: unknown) { + const maps_api_key = key as string | null | undefined; + this.db.run( + 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + maybe_encrypt_api_key(maps_api_key), userId + ); + return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) }; + } + + updateApiKeys(userId: number, rawBody: unknown) { + const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string }; + const current = this.db.get>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId); + + this.db.run( + 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_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, + body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key, + body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current!.unsplash_api_key, + userId + ); + + const updated = this.db.get>( + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?', + userId + ); + + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + return { + success: true, + user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, + }; + } + + updateSettings( + userId: number, + rawBody: unknown + ): { error?: string; status?: number; success?: boolean; user?: Record } { + const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string }; + const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body; + + if (username !== undefined) { + const trimmed = username.trim(); + if (!trimmed || trimmed.length < 2 || trimmed.length > 50) { + return { error: 'Username must be between 2 and 50 characters', status: 400 }; + } + 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); + if (conflict) return { error: 'Username already taken', status: 409 }; + } + + if (email !== undefined) { + const trimmed = email.trim(); + 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); + if (conflict) return { error: 'Email already taken', status: 409 }; + } + + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } + if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } + if (unsplash_api_key !== undefined) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); } + if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); } + if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); } + + if (updates.length > 0) { + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(userId); + this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + + const updated = this.db.get>( + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?', + userId + ); + + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + return { + success: true, + user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, + }; + } + + getSettings(userId: number): { error?: string; status?: number; settings?: Record } { + const user = this.db.get>( + 'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', + userId + ); + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; + + return { + settings: { + maps_api_key: decrypt_api_key(user.maps_api_key), + openweather_api_key: decrypt_api_key(user.openweather_api_key), + unsplash_api_key: decrypt_api_key(user.unsplash_api_key), + }, + }; + } + + // ------------------------------------------------------------------------- + // Avatar + // ------------------------------------------------------------------------- + + async saveAvatar(userId: number, filename: string) { + const current = this.db.get<{ avatar: string | null }>('SELECT avatar FROM users WHERE id = ?', userId); + // Only a locally uploaded file has something to clean up. An OIDC picture URL + // (#1399) has no file on disk, so skip the rm — path.join on a URL is meaningless. + if (current?.avatar && !/^https:\/\//i.test(current.avatar)) { + // Fire-and-forget: leftover files are harmless; the DB update is + // the source of truth for which avatar is current. + const oldPath = path.join(avatarDir, current.avatar); + await fs.promises.rm(oldPath, { force: true }).catch(() => {}); + } + + this.db.run('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', filename, userId); + + const updated = this.db.get>('SELECT id, username, email, role, avatar FROM users WHERE id = ?', 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); + // An OIDC picture URL (#1399) has no local file — only rm an uploaded one. + if (current?.avatar && !/^https:\/\//i.test(current.avatar)) { + const filePath = path.join(avatarDir, current.avatar); + await fs.promises.rm(filePath, { force: true }).catch(() => {}); + } + this.db.run('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', userId); + return { success: true }; + } + + // ------------------------------------------------------------------------- + // User directory + // ------------------------------------------------------------------------- + + 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>( + 'SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC', + excludeUserId + ); + return users.map(u => ({ ...u, avatar_url: avatarUrl(u) })); + } + + // ------------------------------------------------------------------------- + // Key validation + // ------------------------------------------------------------------------- + + 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>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId); + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null }; + + const result: { + 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; + }; + } = { maps: false, weather: false, maps_details: null }; + + const maps_api_key = decrypt_api_key(user.maps_api_key); + if (maps_api_key) { + try { + const mapsRes = await fetch( + `https://places.googleapis.com/v1/places:searchText`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': maps_api_key, + 'X-Goog-FieldMask': 'places.displayName', + }, + body: JSON.stringify({ textQuery: 'test' }), + } + ); + result.maps = mapsRes.status === 200; + let error_text: string | null = null; + let error_json: any = null; + if (!result.maps) { + try { + error_text = await mapsRes.text(); + try { error_json = JSON.parse(error_text); } catch { error_json = null; } + } catch { error_text = null; error_json = null; } + } + result.maps_details = { + ok: result.maps, + status: mapsRes.status, + status_text: mapsRes.statusText || null, + error_message: error_json?.error?.message || null, + error_status: error_json?.error?.status || null, + error_raw: error_text, + }; + } catch (err: unknown) { + result.maps = false; + result.maps_details = { + ok: false, + status: null, + status_text: null, + error_message: err instanceof Error ? err.message : 'Request failed', + error_status: 'FETCH_ERROR', + error_raw: null, + }; + } + } + + const openweather_api_key = decrypt_api_key(user.openweather_api_key); + if (openweather_api_key) { + try { + const weatherRes = await fetch( + `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` + ); + result.weather = weatherRes.status === 200; + } catch { + result.weather = false; + } + } + + return result; + } + + // ------------------------------------------------------------------------- + // Admin settings + // ------------------------------------------------------------------------- + + getAppSettings(userId: number): { error?: string; status?: number; data?: Record } { + 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 result: Record = {}; + 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; + } + return { data: result }; + } + + updateAppSettings( + userId: number, + rawBody: unknown + ): { + error?: string; + status?: number; + success?: boolean; + auditSummary?: Record; + auditDebugDetails?: Record; + shouldRestartScheduler?: boolean; + } { + const body = rawBody as Record; + 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 { 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); + // 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); + 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.', + status: 400, + }; + } + } + + // Lockout prevention: can't disable all login methods + if (body.password_login !== undefined || body.oidc_login !== undefined) { + const current = 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) + ); + 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; + if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) { + return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 }; + } + } + + for (const key of ADMIN_SETTINGS_KEYS) { + if (body[key] !== undefined) { + let val = String(body[key]); + if (key === 'require_mfa') { + val = body[key] === true || val === 'true' ? 'true' : 'false'; + } + if (key === 'smtp_pass' && val === '••••••••') continue; + if (key === 'smtp_pass') val = encrypt_api_key(val); + if (key === 'admin_webhook_url' && val === '••••••••') continue; + 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); + } + } + + const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); + + const summary: Record = {}; + const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); + if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels; + if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true; + if (changedKeys.some(k => k.startsWith('admin_ntfy_'))) summary.admin_ntfy_updated = true; + if (smtpChanged) summary.smtp_settings_updated = true; + if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration; + if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; + if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa; + + const debugDetails: Record = {}; + for (const k of changedKeys) { + debugDetails[k] = k === 'smtp_pass' ? '***' : body[k]; + } + + const notifRelated = ['notification_channels', 'smtp_host']; + const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k)); + if (shouldRestartScheduler) { + startTripReminders(); + } + + return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler }; + } + + // ------------------------------------------------------------------------- + // Travel stats + // ------------------------------------------------------------------------- + + getTravelStats(userId: number) { + const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(` + SELECT DISTINCT p.address, p.lat, p.lng + FROM places p + JOIN trips t ON p.trip_id = t.id + LEFT JOIN trip_members tm ON t.id = tm.trip_id + WHERE t.user_id = ? OR tm.user_id = ? + `, userId, userId); + + // Archived trips still count here, matching the places, countries and flight + // distance widgets (which never filtered on is_archived) so the dashboard stats + // stay consistent — archiving a trip no longer zeroes out trips/days. + const tripStats = this.db.get<{ trips: number; days: number }>(` + SELECT COUNT(DISTINCT t.id) as trips, + COUNT(DISTINCT d.id) as days + FROM trips t + LEFT JOIN days d ON d.trip_id = t.id + LEFT JOIN trip_members tm ON t.id = tm.trip_id + WHERE (t.user_id = ? OR tm.user_id = ?) + `, userId, userId); + + const cities = new Set(); + const coords: { lat: number; lng: number }[] = []; + + places.forEach(p => { + if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng }); + if (p.address) { + const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); + const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); + if (cityPart) cities.add(cityPart); + } + }); + + // Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from + // auto-resolved place regions plus countries the user marked manually. + const countryCodes = new Set(); + const manualCountries = this.db.all<{ country_code: string }>( + 'SELECT country_code FROM visited_countries WHERE user_id = ?', + userId + ); + manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); }); + + const placeRegionCodes = this.db.all<{ country_code: string }>(` + SELECT DISTINCT pr.country_code + FROM place_regions pr + JOIN places p ON p.id = pr.place_id + JOIN trips t ON p.trip_id = t.id + LEFT JOIN trip_members tm ON t.id = tm.trip_id + WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL + `, userId, userId); + placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); }); + + // Transport bookings don't create a place row, so their geocoded endpoints never + // reached place_regions — a country reached only by a flight/train (no lodging or + // planned place there) was never counted as visited (#1366). Resolve each endpoint + // coordinate to a country and fold it in too. + // Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate + // connection/layover (e.g. a plane change) the traveler never really visited (#1486). + const endpoints = this.db.all<{ lat: number; lng: number }>(` + SELECT DISTINCT e.lat, e.lng + FROM reservation_endpoints e + JOIN reservations r ON e.reservation_id = r.id + JOIN trips t ON r.trip_id = t.id + LEFT JOIN trip_members tm ON t.id = tm.trip_id + WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to') + `, userId, userId); + for (const e of endpoints) { + const code = getCountryFromCoords(e.lat, e.lng); + if (code) countryCodes.add(code.toUpperCase()); + } + + // Countries the user removed in Atlas stay removed on the dashboard too, so the + // passport card and the Atlas map agree (#1490). + for (const code of this.atlas.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase()); + + return { + countries: [...countryCodes], + cities: [...cities], + coords, + totalTrips: tripStats?.trips || 0, + totalDays: tripStats?.days || 0, + totalPlaces: places.length, + totalDistanceKm: getFlightDistanceKm(userId), + }; + } + + // ------------------------------------------------------------------------- // MFA - setupMfa(userId: number, email: string) { return auth.setupMfa(userId, email); } - enableMfa(userId: number, code: unknown) { return auth.enableMfa(userId, code as string); } - disableMfa(userId: number, email: string, body: unknown) { return auth.disableMfa(userId, email, body as Parameters[2]); } + // ------------------------------------------------------------------------- - // MCP tokens + short-lived tokens - listMcpTokens(userId: number) { return auth.listMcpTokens(userId); } - createMcpToken(userId: number, name: unknown) { return auth.createMcpToken(userId, name as string); } - deleteMcpToken(userId: number, id: string) { return auth.deleteMcpToken(userId, id); } - createWsToken(userId: number) { return auth.createWsToken(userId); } - createResourceToken(userId: number, purpose: unknown) { return auth.createResourceToken(userId, purpose as string); } + setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise } { + 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); + if (row?.mfa_enabled) { + return { error: 'MFA is already enabled', status: 400 }; + } + let secret: string, otpauth_url: string; + try { + secret = authenticator.generateSecret(); + mfaSetupPending.set(userId, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); + otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret); + } catch (err) { + console.error('[MFA] Setup error:', err); + return { error: 'MFA setup failed', status: 500 }; + } + return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) }; + } + + enableMfa(userId: number, rawCode: unknown): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } { + const code = rawCode as string | undefined; + if (!code) { + return { error: 'Verification code is required', status: 400 }; + } + const pending = this.getPendingMfaSecret(userId); + if (!pending) { + return { error: 'No MFA setup in progress. Start the setup again.', status: 400 }; + } + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret: pending }); + if (!ok) { + return { error: 'Invalid verification code', status: 401 }; + } + 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 + ); + mfaSetupPending.delete(userId); + return { success: true, mfa_enabled: true, backup_codes: backupCodes }; + } + + disableMfa( + userId: number, + userEmail: string, + rawBody: unknown + ): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } { + const body = rawBody as { password?: string; code?: string }; + 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') { + 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('SELECT * FROM users WHERE id = ?', userId); + if (!user?.mfa_enabled || !user.mfa_secret) { + return { error: 'MFA is not enabled', status: 400 }; + } + if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { + return { error: 'Incorrect password', status: 401 }; + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret }); + 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 + ); + mfaSetupPending.delete(userId); + return { success: true, mfa_enabled: false }; + } + + verifyMfaLogin(rawBody: unknown): { + error?: string; + status?: number; + token?: string; + user?: Record; + remember?: boolean; + auditUserId?: number; + } { + const body = rawBody as { mfa_token?: string; code?: string; remember_me?: boolean }; + const { mfa_token, code, remember_me } = body; + const remember = remember_me === true; + if (!mfa_token || !code) { + return { error: 'Verification token and code are required', status: 400 }; + } + try { + const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string }; + if (decoded.purpose !== 'mfa_login') { + return { error: 'Invalid verification token', status: 401 }; + } + const user = this.db.get('SELECT * FROM users WHERE id = ?', decoded.id); + if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { + return { error: 'Invalid session', status: 401 }; + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).trim(); + const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret }); + if (!okTotp) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + // matchBackupCode handles both bcrypt and legacy SHA-256 hashes; + // any store older than the bcrypt migration keeps working. + const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h)); + if (idx === -1) { + return { error: 'Invalid verification code', status: 401 }; + } + hashes.splice(idx, 1); + 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); + const sessionToken = this.generateToken(user, remember); + const userSafe = stripUserForClient(user) as Record; + return { + token: sessionToken, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + remember, + auditUserId: Number(user.id), + }; + } catch { + return { error: 'Invalid or expired verification token', status: 401 }; + } + } + + // ------------------------------------------------------------------------- + // Password reset + // ------------------------------------------------------------------------- + + requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome { + const email = String(rawEmail || '').trim().toLowerCase(); + // Basic shape check — a fully empty / malformed email is treated like + // "no user" so we still spend the same time internally. + const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email); + + // Global policy check: password login disabled → no reset possible. + const toggles = this.resolveAuthToggles(); + if (!toggles.password_login) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' }; + } + + // Per-email throttle. We check this BEFORE the DB lookup so the timing + // is identical regardless of whether the account exists. + const throttleKey = email || '__noemail__'; + const now = Date.now(); + const record = perEmailResetAttempts.get(throttleKey); + if (record && record.count >= PASSWORD_RESET_PER_EMAIL_MAX && now - record.first < PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'throttled_per_email' }; + } + if (!record || now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { + perEmailResetAttempts.set(throttleKey, { count: 1, first: now }); + } else { + record.count++; + } + + if (!looksLikeEmail) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; + } + + // 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 + ); + + if (!user) { + return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; + } + // SSO-linked account — refuse a reset. OIDC users are created with a random + // bcrypt hash (so password_hash is never empty), which is why we must key off + // oidc_sub rather than a missing hash. Letting the reset proceed would set a + // local password and revoke session/credential state, which breaks the SSO + // login; admins (or the user, with their current password) can still set one. + // The client still gets the generic "if that email exists…" response. + if (user.oidc_sub) { + return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' }; + } + + // 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 + ); + + 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 + ); + + return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' }; + } + + /** + * Consume a reset token and set a new password. If the target user has + * MFA enabled, a valid TOTP code or backup code must be supplied — a + * compromised email alone therefore does NOT allow taking over a + * 2FA-protected account. + */ + resetPassword(rawBody: unknown): ResetPasswordOutcome { + const body = rawBody as { token?: string; new_password?: string; mfa_code?: string }; + const { token, new_password, mfa_code } = body; + if (!token || typeof token !== 'string') { + return { error: 'Reset token is required', status: 400 }; + } + if (!new_password || typeof new_password !== 'string') { + return { error: 'New password is required', status: 400 }; + } + // Check the policy BEFORE touching the token so an invalid password + // does not burn the user's one-time link. + const pwCheck = validatePassword(new_password); + 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 + ); + + 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 }; + if (new Date(row.expires_at).getTime() < Date.now()) { + 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 + ); + + 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; + let backupCodeConsumedIndex: number | null = null; + if (mfaOn) { + if (!user.mfa_secret) { + // Data inconsistency — fail closed. + return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 }; + } + const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : ''; + if (!supplied) return { mfa_required: true, status: 200 }; + + const secret = decryptMfaSecret(user.mfa_secret); + const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret }); + if (!okTotp) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + const idx = hashes.findIndex((h) => matchBackupCode(supplied, h)); + if (idx === -1) return { error: 'Invalid MFA code', status: 401 }; + backupCodeConsumedIndex = idx; + } + } + + const newHash = bcrypt.hashSync(new_password, BCRYPT_COST); + const newPv = (user.password_version ?? 0) + 1; + + this.db.transaction(() => { + // 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); + // 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 + ); + // 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); + } + // 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); + try { + this.db.run( + "UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", + user.id + ); + } catch { /* oauth_tokens table may not exist in very old installs */ } + }); + + // Kick off any MCP/WS session cleanup — same hook the account-delete path uses. + try { revokeUserSessions?.(user.id); } catch { /* best-effort */ } + + return { success: true, userId: user.id }; + } + + // ------------------------------------------------------------------------- + // MCP tokens + // ------------------------------------------------------------------------- + + listMcpTokens(userId: number) { + return this.db.all( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC', + userId + ); + } + + createMcpToken(userId: number, rawName: unknown): { error?: string; status?: number; token?: Record } { + const name = rawName as string | undefined; + if (!name?.trim()) return { error: 'Token name is required', status: 400 }; + if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; + + const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?', userId)!.count; + if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 }; + + const rawToken = 'trek_' + randomBytes(24).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const tokenPrefix = rawToken.slice(0, 13); + + const result = this.db.run( + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)', + userId, name.trim(), tokenHash, tokenPrefix + ); + + const token = this.db.get( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?', + result.lastInsertRowid + ); + + return { token: { ...(token as object), raw_token: rawToken } }; + } + + deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } { + const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?', tokenId, userId); + if (!token) return { error: 'Token not found', status: 404 }; + this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId); + revokeUserSessions(userId); + return { success: true }; + } + + // ------------------------------------------------------------------------- + // Ephemeral tokens + // ------------------------------------------------------------------------- + + createWsToken(userId: number): { error?: string; status?: number; token?: string } { + // Bind the ws-token to the user's current password_version so a token minted + // before a password reset is rejected on connect (defence-in-depth session gate). + const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0; + const token = createEphemeralToken(userId, 'ws', { pv }); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; + } + + createResourceToken(userId: number, rawPurpose: unknown): { error?: string; status?: number; token?: string } { + const purpose = rawPurpose as string | undefined; + if (purpose !== 'download') { + return { error: 'Invalid purpose', status: 400 }; + } + const token = createEphemeralToken(userId, purpose); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; + } + + // ------------------------------------------------------------------------- + // MCP auth helpers + // ------------------------------------------------------------------------- + + isDemoUser(userId: number): 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); + } + + verifyMcpToken(rawToken: string): User | null { + const hash = createHash('sha256').update(rawToken).digest('hex'); + const row = this.db.get(` + SELECT u.id, u.username, u.email, u.role + FROM mcp_tokens mt + JOIN users u ON mt.user_id = u.id + WHERE mt.token_hash = ? + `, hash); + if (row) { + this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash); + return row; + } + return null; + } + + /** + * Verify a JWT the same way `middleware/auth.ts#verifyJwtAndLoadUser` + * does — including the `password_version` check — so that stolen tokens + * lose access the moment the victim resets their password. + * + * This is the single entry point every non-cookie JWT verification path + * (MCP bearer, WebSocket handshake, file-download query tokens, photo + * route) should go through. + */ + verifyJwtToken(token: string): User | null { + return verifyJwtAndLoadUser(token); + } } diff --git a/server/src/nest/auth/passkey-enabled.guard.ts b/server/src/nest/auth/passkey-enabled.guard.ts index 26954162a..296675391 100644 --- a/server/src/nest/auth/passkey-enabled.guard.ts +++ b/server/src/nest/auth/passkey-enabled.guard.ts @@ -1,5 +1,5 @@ import { CanActivate, HttpException, Injectable } from '@nestjs/common'; -import { resolveAuthToggles } from '../../services/authService'; +import { AuthService } from './auth.service'; /** * Server-side enforcement of the instance-wide `passkey_login` toggle. Placed @@ -13,8 +13,10 @@ import { resolveAuthToggles } from '../../services/authService'; */ @Injectable() export class PasskeyEnabledGuard implements CanActivate { + constructor(private readonly auth: AuthService) {} + canActivate(): boolean { - if (!resolveAuthToggles().passkey_login) { + if (!this.auth.resolveAuthToggles().passkey_login) { throw new HttpException({ error: 'Passkey login is not enabled' }, 404); } return true; diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index f929b65c6..073b6ca62 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -68,6 +68,7 @@ export class BudgetMcp { private readonly budget: BudgetService, private readonly exchangeRates: ExchangeRatesService, private readonly db: DatabaseService, + private readonly auth: AuthService, ) {} // --- BUDGET --- @@ -97,7 +98,7 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); const members = resolveMemberIds(tripId, member_ids); @@ -122,7 +123,7 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.budget.deleteBudgetItem(itemId, tripId); @@ -159,7 +160,7 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Freeze-then-write composite (no-op while the schema has no currency input, @@ -193,7 +194,7 @@ export class BudgetMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Omitted userIds → default to the whole trip, matching create_budget_item. @@ -224,7 +225,7 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); const result = this.budget.updateMembers(itemId, tripId, userIds); @@ -248,7 +249,7 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid); @@ -311,7 +312,7 @@ export class BudgetMcp { { tripId, from_user_id, to_user_id, amount }: { tripId: number; from_user_id: number; to_user_id: number; amount: number }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Freeze-then-write composite, same as the REST path (no-op while the @@ -339,7 +340,7 @@ export class BudgetMcp { { tripId, settlementId, from_user_id, to_user_id, amount }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); // Freeze-then-write composite, same as the REST path. @@ -361,7 +362,7 @@ export class BudgetMcp { access: { group: 'budget', mode: 'write' }, }) async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.budget.deleteSettlement(settlementId, tripId); diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index 0d4c8abb8..5ca713d1f 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -4,11 +4,12 @@ import { BudgetService } from './budget.service'; import { BudgetMcp } from './budget.mcp'; import { ExchangeRatesService } from './exchange-rates.service'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; /** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule. * BudgetMcp carries the decorator-registered MCP tools + resources. */ @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AuthModule], controllers: [BudgetController], providers: [BudgetService, ExchangeRatesService, BudgetMcp], // For in-container consumers (PluginHostDepsFactory, TripsService, diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index 4dc2434ff..716237745 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -59,7 +59,7 @@ function jsonContent(uri: string, data: unknown) { */ @McpController() export class CollabMcp { - constructor(private readonly collab: CollabService) {} + constructor(private readonly collab: CollabService, private readonly auth: AuthService) {} // --- COLLAB NOTES --- @@ -84,7 +84,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, pinned }); @@ -114,7 +114,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, pinned }); @@ -135,7 +135,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.collab.deleteNote(tripId, noteId); @@ -182,7 +182,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); @@ -203,7 +203,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); @@ -224,7 +224,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const poll = this.collab.closePoll(tripId, pollId); @@ -245,7 +245,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.collab.deletePoll(tripId, pollId); @@ -284,7 +284,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); @@ -305,7 +305,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const result = this.collab.deleteMessage(tripId, messageId, ctx.userId); @@ -327,7 +327,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied(); const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); diff --git a/server/src/nest/collab/collab.module.ts b/server/src/nest/collab/collab.module.ts index c7b3709a7..b2b99a597 100644 --- a/server/src/nest/collab/collab.module.ts +++ b/server/src/nest/collab/collab.module.ts @@ -3,9 +3,10 @@ import { CollabController } from './collab.controller'; import { CollabService } from './collab.service'; import { CollabMcp } from './collab.mcp'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AuthModule], controllers: [CollabController], providers: [CollabService, CollabMcp], // For in-container consumers (PluginHostDepsFactory). diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index f90a32d3f..0883974eb 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { DatabaseService } from '../database/database.service'; @@ -56,10 +56,11 @@ export class CollectionsMcp { constructor( private readonly collections: CollectionsService, private readonly db: DatabaseService, + private readonly auth: AuthService, ) {} private denyDemo(userId: number) { - return isDemoUser(userId) ? demoDenied() : null; + return this.auth.isDemoUser(userId) ? demoDenied() : null; } // ── Read ────────────────────────────────────────────────────────────── diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index 2cfe2e9e2..3301e7038 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -5,12 +5,13 @@ import { CollectionsMcp } from './collections.mcp'; import { CollectionsAddonGuard } from './collections-addon.guard'; import { AddonsModule } from '../addons/addons.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; /** Collections domain (saved-places library). Registered in AppModule. * Exports CollectionsService for in-container consumers (PluginsModule's * RPC host deps factory). */ @Module({ - imports: [AddonsModule, PermissionsModule], + imports: [AddonsModule, PermissionsModule, AuthModule], controllers: [CollectionsController], providers: [CollectionsService, CollectionsMcp, CollectionsAddonGuard], exports: [CollectionsService], diff --git a/server/src/nest/days/day-notes.mcp.ts b/server/src/nest/days/day-notes.mcp.ts index 49bd41148..e5d36a751 100644 --- a/server/src/nest/days/day-notes.mcp.ts +++ b/server/src/nest/days/day-notes.mcp.ts @@ -4,7 +4,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { DayNotesService } from './day-notes.service'; @@ -25,7 +25,7 @@ function parseId(value: string | string[]): number | null { */ @McpController() export class DayNotesMcp { - constructor(private readonly notes: DayNotesService) {} + constructor(private readonly notes: DayNotesService, private readonly auth: AuthService) {} @Tool({ name: 'create_day_note', @@ -44,7 +44,7 @@ export class DayNotesMcp { { tripId, dayId, text, time, icon }: { tripId: number; dayId: number; text: string; time?: string; icon?: string }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.notes.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; @@ -71,7 +71,7 @@ export class DayNotesMcp { { tripId, dayId, noteId, text, time, icon }: { tripId: number; dayId: number; noteId: number; text?: string; time?: string | null; icon?: string }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const existing = this.notes.getNote(noteId, dayId, tripId); @@ -93,7 +93,7 @@ export class DayNotesMcp { access: { group: 'trips', mode: 'write' }, }) async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const note = this.notes.getNote(noteId, dayId, tripId); diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index 85132ac55..a6a6745ee 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -4,7 +4,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { PlacesService } from '../places/places.service'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { DatabaseService } from '../database/database.service'; @@ -34,6 +34,7 @@ export class DaysMcp { private readonly days: DaysService, private readonly db: DatabaseService, private readonly places: PlacesService, + private readonly auth: AuthService, ) {} @Tool({ @@ -51,7 +52,7 @@ export class DaysMcp { { tripId, dayId, title }: { tripId: number; dayId: number; title: string | null }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const current = this.days.getDay(dayId, tripId); @@ -76,7 +77,7 @@ export class DaysMcp { { tripId, date, notes }: { tripId: number; date?: string; notes?: string }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const day = this.days.create(tripId, date, notes); @@ -95,7 +96,7 @@ export class DaysMcp { access: { group: 'trips', mode: 'write' }, }) async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; @@ -130,7 +131,7 @@ export class DaysMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const errors = this.days.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); @@ -180,7 +181,7 @@ export class DaysMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const dayErrors = this.days.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); @@ -224,7 +225,7 @@ export class DaysMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const existing = this.days.getAccommodation(accommodationId, tripId); @@ -245,7 +246,7 @@ export class DaysMcp { access: { group: 'trips', mode: 'write' }, }) async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.days.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; diff --git a/server/src/nest/days/days.module.ts b/server/src/nest/days/days.module.ts index b32eef0f8..c6f846f6e 100644 --- a/server/src/nest/days/days.module.ts +++ b/server/src/nest/days/days.module.ts @@ -7,6 +7,7 @@ import { DayNotesService } from './day-notes.service'; import { DayNotesMcp } from './day-notes.mcp'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlacesModule } from '../places/places.module'; +import { AuthModule } from '../auth/auth.module'; /** * Days + day-notes domain (S6 — Phase 2 trip sub-domain). The single prefix @@ -18,7 +19,7 @@ import { PlacesModule } from '../places/places.module'; * controllers). */ @Module({ - imports: [PermissionsModule, PlacesModule], + imports: [PermissionsModule, PlacesModule, AuthModule], controllers: [DaysController, DayNotesController], providers: [DaysService, DaysMcp, DayNotesService, DayNotesMcp], exports: [DaysService, DayNotesService], diff --git a/server/src/nest/oidc/oidc.module.ts b/server/src/nest/oidc/oidc.module.ts index e2107bc59..090ec21ce 100644 --- a/server/src/nest/oidc/oidc.module.ts +++ b/server/src/nest/oidc/oidc.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { OidcController } from './oidc.controller'; import { OidcService } from './oidc.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [OidcController], providers: [OidcService], }) diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index d9ad28c51..0fb165415 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { Request, Response } from 'express'; import * as oidc from '../../services/oidcService'; import { getAppUrl } from '../../app-config'; -import { resolveAuthToggles } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { setAuthCookie } from '../../services/cookie'; /** @@ -12,7 +12,9 @@ import { setAuthCookie } from '../../services/cookie'; */ @Injectable() export class OidcService { - oidcLoginEnabled(): boolean { return resolveAuthToggles().oidc_login; } + constructor(private readonly auth: AuthService) {} + + oidcLoginEnabled(): boolean { return this.auth.resolveAuthToggles().oidc_login; } getOidcConfig() { return oidc.getOidcConfig(); } getAppUrl() { return getAppUrl(); } discover(issuer: string, discoveryUrl?: string | null) { return oidc.discover(issuer, discoveryUrl); } diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 0ce6409d1..a13eb0642 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { deletePackingTemplate } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; @@ -34,7 +34,7 @@ function parseId(value: string | string[]): number | null { */ @McpController() export class PackingMcp { - constructor(private readonly packing: PackingService) {} + constructor(private readonly packing: PackingService, private readonly auth: AuthService) {} // --- PACKING --- @@ -51,7 +51,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async createPackingItem({ tripId, name, category }: { tripId: number; name: string; category?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const item = this.packing.createItem(tripId, { name, category: category || 'General' }, ctx.userId); @@ -72,7 +72,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']); @@ -93,7 +93,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.packing.deleteItem(tripId, itemId); @@ -118,7 +118,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async updatePackingItem({ tripId, itemId, name, category }: { tripId: number; itemId: number; name?: string; category?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined); @@ -142,7 +142,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); this.packing.reorderItems(tripId, orderedIds); @@ -179,7 +179,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async createPackingBag({ tripId, name, color }: { tripId: number; name: string; color?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); // createBag returns a bare row; hydrate with the empty members array that @@ -203,7 +203,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async updatePackingBag({ tripId, bagId, name, color }: { tripId: number; bagId: number; name?: string; color?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const fields: Record = {}; @@ -230,7 +230,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); this.packing.deleteBag(tripId, bagId); @@ -253,7 +253,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const members = this.packing.setBagMembers(tripId, bagId, userIds); @@ -291,7 +291,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds); @@ -311,7 +311,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const items = this.packing.applyTemplate(tripId, templateId); @@ -347,7 +347,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); // Templates are global; the REST route restricts saving to admins. Match it. @@ -368,7 +368,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Templates are global; the REST route restricts management to admins. Match it. if (!isAdminUser(ctx.userId)) return adminRequired(); const result = deletePackingTemplate(String(templateId)); @@ -398,7 +398,7 @@ export class PackingMcp { { tripId, items }: { tripId: number; items: { name: string; category?: string; quantity?: number; bag?: string; weight_grams?: number; checked?: boolean }[] }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const created = this.packing.bulkImport(tripId, items, ctx.userId); diff --git a/server/src/nest/packing/packing.module.ts b/server/src/nest/packing/packing.module.ts index 6659de87b..75b3cad90 100644 --- a/server/src/nest/packing/packing.module.ts +++ b/server/src/nest/packing/packing.module.ts @@ -3,12 +3,13 @@ import { PackingController } from './packing.controller'; import { PackingMcp } from './packing.mcp'; import { PackingService } from './packing.service'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; /** Packing domain (S2 — Phase 2 trip sub-domain). Registered in AppModule. * Exports PackingService for in-container consumers (TripsService bundle, * PluginHostDepsFactory). */ @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AuthModule], controllers: [PackingController], providers: [PackingService, PackingMcp], exports: [PackingService], diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 9865f9de5..684c17f6b 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { createAssignment, dayExists } from '../assignments/assignments.bridge'; import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -42,6 +42,7 @@ export class PlacesMcp { private readonly places: PlacesService, private readonly maps: MapsService, private readonly db: DatabaseService, + private readonly auth: AuthService, ) {} @Tool({ @@ -75,7 +76,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }); @@ -117,7 +118,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; @@ -174,7 +175,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); const place = this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }); @@ -198,7 +199,7 @@ export class PlacesMcp { { tripId, placeId, rating }: { tripId: number; placeId: number; rating?: number | null }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Rating is a personal vote — any trip member may cast one, place_edit not required. if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); const place = this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null); @@ -218,7 +219,7 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); // Scope the id to the trip before the hook: onPlaceDeleted keys on the place @@ -297,7 +298,7 @@ export class PlacesMcp { { tripId, url, source }: { tripId: number; url: string; source: 'google-list' | 'naver-list' }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); @@ -326,7 +327,7 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); @@ -371,7 +372,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 7e4c983b6..a1b269ad5 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -4,6 +4,7 @@ import { PlacesService } from './places.service'; import { PlacesMcp } from './places.mcp'; import { PermissionsModule } from '../permissions/permissions.module'; import { MapsModule } from '../maps/maps.module'; +import { AuthModule } from '../auth/auth.module'; /** * Places domain (S8 — Phase 2 trip sub-domain). Depends on L4 Categories + L5 @@ -14,7 +15,7 @@ import { MapsModule } from '../maps/maps.module'; * is no places.bridge.ts: nothing outside the container consumes this domain. */ @Module({ - imports: [PermissionsModule, MapsModule], + imports: [PermissionsModule, MapsModule, AuthModule], controllers: [PlacesController], providers: [PlacesService, PlacesMcp], exports: [PlacesService], diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 7a8fd3c78..9ba9adec3 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -4,7 +4,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { BudgetService } from '../budget/budget.service'; import { placeExists, getAssignmentForTrip } from '../assignments/assignments.bridge'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -32,6 +32,7 @@ export class ReservationsMcp { private readonly reservations: ReservationsService, private readonly days: DaysService, private readonly budget: BudgetService, + private readonly auth: AuthService, ) {} @Tool({ @@ -67,7 +68,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); @@ -141,7 +142,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); const existing = this.reservations.getReservation(reservationId, tripId); @@ -172,7 +173,7 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'write' }, }) async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); const { deleted, accommodationDeleted } = this.reservations.remove(reservationId, tripId); @@ -202,7 +203,7 @@ export class ReservationsMcp { { tripId, positions, dayId }: { tripId: number; positions: { id: number; day_plan_position: number }[]; dayId?: number }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); this.reservations.updatePositions(tripId, positions, dayId); @@ -232,7 +233,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); const current = this.reservations.getReservation(reservationId, tripId); diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index 1b7811402..748327ee6 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -8,6 +8,7 @@ import { ReservationsMcp } from './reservations.mcp'; import { AccommodationsController } from './accommodations.controller'; import { AccommodationsService } from './accommodations.service'; import { UpcomingReservationsController } from './upcoming-reservations.controller'; +import { AuthModule } from '../auth/auth.module'; /** * Reservations + accommodations domain (S5 — Phase 2 trip sub-domain). @@ -18,7 +19,7 @@ import { UpcomingReservationsController } from './upcoming-reservations.controll @Module({ // DaysModule: AccommodationsService + ReservationsMcp inject DaysService. // BudgetModule: ReservationsService + ReservationsMcp inject BudgetService (budget-sync seam). - imports: [DaysModule, PermissionsModule, BudgetModule], + imports: [DaysModule, PermissionsModule, BudgetModule, AuthModule], controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], providers: [ReservationsService, AccommodationsService, ReservationsMcp], // For in-container consumers (PluginHostDepsFactory, TripsService, BookingImportService). diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 0536231e7..0a2806573 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -4,7 +4,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { canShareTrips } from '../../mcp/scopes'; import { ShareService } from './share.service'; @@ -19,7 +19,7 @@ import { ShareService } from './share.service'; */ @McpController() export class ShareMcp { - constructor(private readonly share: ShareService) {} + constructor(private readonly share: ShareService, private readonly auth: AuthService) {} @Tool({ name: 'get_share_link', @@ -58,7 +58,7 @@ export class ShareMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied(); // The zod .default()s above fill omitted flags, and ShareService applies @@ -79,7 +79,7 @@ export class ShareMcp { access: (ctx) => canShareTrips(ctx.scopes), }) async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied(); this.share.remove(String(tripId)); diff --git a/server/src/nest/share/share.module.ts b/server/src/nest/share/share.module.ts index 0dfc5423a..cb32e6d52 100644 --- a/server/src/nest/share/share.module.ts +++ b/server/src/nest/share/share.module.ts @@ -4,9 +4,10 @@ import { ShareService } from './share.service'; import { ShareMcp } from './share.mcp'; import { SettingsModule } from '../settings/settings.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [SettingsModule, PermissionsModule], + imports: [SettingsModule, PermissionsModule, AuthModule], controllers: [TripShareController, SharedController], providers: [ShareService, ShareMcp], }) diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts index de29779f2..a13ae6736 100644 --- a/server/src/nest/tags/tags.mcp.ts +++ b/server/src/nest/tags/tags.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { TagsService } from './tags.service'; /** @@ -16,7 +16,7 @@ import { TagsService } from './tags.service'; */ @McpController() export class TagsMcp { - constructor(private readonly tags: TagsService) {} + constructor(private readonly tags: TagsService, private readonly auth: AuthService) {} @Tool({ name: 'list_tags', @@ -41,7 +41,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const tag = this.tags.create(ctx.userId, name, color); return ok({ tag }); } @@ -58,7 +58,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.'); const tag = this.tags.update(tagId, name, color); if (!tag) return errorResult('Tag not found.'); @@ -75,7 +75,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.'); this.tags.remove(tagId); return ok({ success: true }); diff --git a/server/src/nest/tags/tags.module.ts b/server/src/nest/tags/tags.module.ts index 12ba2d702..aa3d685ae 100644 --- a/server/src/nest/tags/tags.module.ts +++ b/server/src/nest/tags/tags.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { TagsController } from './tags.controller'; import { TagsMcp } from './tags.mcp'; import { TagsService } from './tags.service'; +import { AuthModule } from '../auth/auth.module'; /** Tags domain (L5 leaf module). Registered in AppModule. */ @Module({ + imports: [AuthModule], controllers: [TagsController], providers: [TagsService, TagsMcp], // For in-container consumers (PluginHostDepsFactory). diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 16763ba2f..66feac2ea 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -30,7 +30,7 @@ function parseId(value: string | string[]): number | null { */ @McpController() export class TodoMcp { - constructor(private readonly todos: TodoService) {} + constructor(private readonly todos: TodoService, private readonly auth: AuthService) {} @Tool({ name: 'list_todos', @@ -70,7 +70,7 @@ export class TodoMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const item = this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); @@ -101,7 +101,7 @@ export class TodoMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); // Build bodyKeys to signal which nullable fields were explicitly provided @@ -129,7 +129,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const item = this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); @@ -150,7 +150,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const deleted = this.todos.deleteItem(tripId, itemId); @@ -171,7 +171,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); this.todos.reorderItems(tripId, orderedIds); @@ -207,7 +207,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied(); const assignees = this.todos.updateCategoryAssignees(tripId, categoryName, userIds); diff --git a/server/src/nest/todo/todo.module.ts b/server/src/nest/todo/todo.module.ts index be7d76f57..e37020f77 100644 --- a/server/src/nest/todo/todo.module.ts +++ b/server/src/nest/todo/todo.module.ts @@ -3,11 +3,12 @@ import { TodoController } from './todo.controller'; import { TodoMcp } from './todo.mcp'; import { TodoService } from './todo.service'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AuthModule } from '../auth/auth.module'; /** To-do domain (S3 — Phase 2 trip sub-domain). Registered in AppModule. * Exports TodoService for in-container consumers (TripsService bundle). */ @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AuthModule], controllers: [TodoController], providers: [TodoService, TodoMcp], exports: [TodoService], diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index e17c95f11..bb0d2c7df 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -4,7 +4,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { buildTransitReservationParts, cleanTransitItineraryNames, @@ -63,6 +63,7 @@ export class TransitMcp { private readonly days: DaysService, private readonly reservations: ReservationsService, private readonly db: DatabaseService, + private readonly auth: AuthService, ) {} @Tool({ @@ -180,7 +181,7 @@ export class TransitMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); const day = this.days.getDay(dayId, tripId); diff --git a/server/src/nest/transit/transit.module.ts b/server/src/nest/transit/transit.module.ts index 42f10fc87..0bb3fcce5 100644 --- a/server/src/nest/transit/transit.module.ts +++ b/server/src/nest/transit/transit.module.ts @@ -5,6 +5,7 @@ import { TransitMcp } from './transit.mcp'; import { RateLimitService } from '../auth/rate-limit.service'; import { DaysModule } from '../days/days.module'; import { ReservationsModule } from '../reservations/reservations.module'; +import { AuthModule } from '../auth/auth.module'; /** * Transit domain (#1065) — the Transitous/MOTIS proxy. TransitMcp carries the @@ -13,7 +14,7 @@ import { ReservationsModule } from '../reservations/reservations.module'; */ @Module({ // DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both. - imports: [DaysModule, ReservationsModule], + imports: [DaysModule, ReservationsModule, AuthModule], controllers: [TransitController], providers: [TransitService, TransitMcp, RateLimitService], exports: [TransitService], diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index c3d299988..9782c8d3b 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -5,7 +5,7 @@ import { demoDenied, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, MAX_MCP_TRIP_DAYS, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; @@ -62,6 +62,7 @@ export class TripsMcp { private readonly trips: TripsService, private readonly todos: TodoService, private readonly collab: CollabService, + private readonly auth: AuthService, ) {} // --- TRIPS --- @@ -85,7 +86,7 @@ export class TripsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (start_date) { const d = new Date(start_date + 'T00:00:00Z'); if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date) @@ -128,7 +129,7 @@ export class TripsMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!hasTripPermission('trip_edit', tripId, ctx.userId)) return permissionDenied(); if (start_date) { @@ -158,7 +159,7 @@ export class TripsMcp { access: (ctx) => canDeleteTrips(ctx.scopes), }) async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.isOwner(tripId, ctx.userId)) return noAccess(); this.trips.remove(tripId, ctx.userId, 'user'); return ok({ success: true, tripId }); @@ -285,7 +286,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) @@ -311,7 +312,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) @@ -332,7 +333,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); try { const newTripId = this.trips.copy(tripId, ctx.userId, title); diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index 80f839e96..3867b5975 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -13,11 +13,12 @@ import { PlacesModule } from '../places/places.module'; import { TripsController } from './trips.controller'; import { TripsService } from './trips.service'; import { TripsMcp } from './trips.mcp'; +import { AuthModule } from '../auth/auth.module'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule], controllers: [TripsController], providers: [TripsService, TripsMcp], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index a6bb010f1..d2c440766 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -6,7 +6,7 @@ import { demoDenied, errorResult, ok, } from '@trek/nest-mcp'; import { z } from 'zod'; -import { isDemoUser, getCurrentUser } from '../../services/authService'; +import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { VacayService } from './vacay.service'; @@ -30,7 +30,7 @@ const vacayAddonOn = () => isAddonEnabled(ADDON_IDS.VACAY); */ @McpController() export class VacayMcp { - constructor(private readonly vacay: VacayService) {} + constructor(private readonly vacay: VacayService, private readonly auth: AuthService) {} @Tool({ name: 'get_vacay_plan', @@ -65,7 +65,7 @@ export class VacayMcp { }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); // updatePlan already returns the fully-hydrated { plan }; surface it so the // AI consumer sees the updated plan, matching get_vacay_plan. @@ -84,7 +84,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async setVacayColor({ color }: { color: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.setUserColor(ctx.userId, planId, color, undefined); // Echo the persisted color (mirrors the service default) so the AI consumer sees what was set. @@ -116,9 +116,9 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); - const me = getCurrentUser(ctx.userId); + const me = this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId); if (result.error) return errorResult(result.error); @@ -136,7 +136,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const result = this.vacay.acceptInvite(ctx.userId, planId, undefined); if (result.error) return errorResult(result.error); return ok({ success: true }); @@ -153,7 +153,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); this.vacay.declineInvite(ctx.userId, planId, undefined); return ok({ success: true }); } @@ -169,7 +169,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.cancelInvite(planId, targetUserId); return ok({ success: true }); @@ -184,7 +184,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async dissolveVacayPlan(_args: Record, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); this.vacay.dissolvePlan(ctx.userId, undefined); return ok({ success: true }); } @@ -214,7 +214,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async addVacayYear({ year }: { year: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const years = this.vacay.addYear(planId, year, undefined); return ok({ years }); @@ -231,7 +231,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async deleteVacayYear({ year }: { year: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const years = this.vacay.deleteYear(planId, year, undefined); return ok({ years }); @@ -264,7 +264,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async toggleVacayEntry({ date }: { date: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const result = this.vacay.toggleEntry(ctx.userId, planId, date, 1, 'vacation', undefined); return ok(result); @@ -282,7 +282,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined); return ok(result); @@ -316,7 +316,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined); return ok({ success: true }); @@ -339,7 +339,7 @@ export class VacayMcp { { region, label, color, sortOrder }: { region: string; label?: string | null; color?: string; sortOrder?: number }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined); return ok({ calendar }); @@ -361,7 +361,7 @@ export class VacayMcp { { calendarId, label, color }: { calendarId: number; label?: string | null; color?: string }, ctx: McpContext, ) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined); if (!cal) return errorResult('Holiday calendar not found.'); @@ -379,7 +379,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.deleteHolidayCalendar(calendarId, planId, undefined); return ok({ success: true }); @@ -439,8 +439,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); - const me = getCurrentUser(ctx.userId); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + const me = this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId); if (result.error) return errorResult(result.error); @@ -458,7 +458,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) { - if (isDemoUser(ctx.userId)) return demoDenied(); + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) { return errorResult('Share not found.'); } diff --git a/server/src/nest/vacay/vacay.module.ts b/server/src/nest/vacay/vacay.module.ts index f4e6b3116..954b10d21 100644 --- a/server/src/nest/vacay/vacay.module.ts +++ b/server/src/nest/vacay/vacay.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { VacayController } from './vacay.controller'; import { VacayService } from './vacay.service'; import { VacayMcp } from './vacay.mcp'; +import { AuthModule } from '../auth/auth.module'; /** * Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule. @@ -9,6 +10,7 @@ import { VacayMcp } from './vacay.mcp'; * VacayMcp carries the DI-discovered MCP tools/resources. */ @Module({ + imports: [AuthModule], controllers: [VacayController], providers: [VacayService, VacayMcp], exports: [VacayService], diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index ba9702b44..495d0d51a 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -9,7 +9,7 @@ import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp/sessionM import { emitUserDeleted } from '../plugin-user-lifecycle'; import { User, Addon } from '../types'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; -import { resolveAuthToggles } from './authService'; +import { resolveAuthToggles } from '../nest/auth/auth.bridge'; import { avatarUrl } from './avatarUrl'; import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig'; import { getPhotoProviderConfig } from './memories/helpersService'; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts deleted file mode 100644 index c9b09b85b..000000000 --- a/server/src/services/authService.ts +++ /dev/null @@ -1,1497 +0,0 @@ -import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import crypto from 'crypto'; -import { readEnv } from '../app-config'; -import path from 'path'; -import fs from 'fs'; -import { authenticator } from 'otplib'; -import QRCode from 'qrcode'; -import { randomBytes, createHash } from 'crypto'; -import { db } from '../db/database'; -import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config'; -import { validatePassword } from './passwordPolicy'; -import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto'; -import { getAllPermissions } from '../nest/permissions/permissions.bridge'; -import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKeyCrypto'; -import { createEphemeralToken } from './ephemeralTokens'; -// Import from sessionManager directly, NOT the ../mcp barrel: the barrel pulls -// the whole tools fan-out (and via the domain bridges, the Nest services) into -// every consumer of this module — a services→mcp→nest module cycle. -import { revokeUserSessions } from '../mcp/sessionManager'; -import { startTripReminders } from '../scheduler'; -import { deleteUserCompletely } from './userCleanupService'; -import { emitUserDeleted } from '../plugin-user-lifecycle'; -import { getFlightDistanceKm } from './distanceService'; -import { getCountryFromCoords, getHiddenCountries } from '../nest/atlas/atlas.bridge'; -import { verifyJwtAndLoadUser } from '../middleware/auth'; -import { User } from '../types'; -import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo'; -import { avatarUrl } from './avatarUrl'; -import { joinTripAsMember } from './tripMembership'; -import { isPasskeyConfigured } from './webauthnConfig'; - -export { avatarUrl }; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -authenticator.options = { window: 1 }; - -// bcrypt cost factor for user passwords. Shared by register/changePassword/ -// resetPassword and the dummy-hash timing equaliser below — must stay in sync. -const BCRYPT_COST = 12; - -// Shape check for email input on register and profile update. -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -// Pre-computed bcrypt hash to equalise timing of "unknown email" and -// "OIDC-only account" branches with the real verification path (CWE-208). -const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', BCRYPT_COST); - -const MFA_SETUP_TTL_MS = 15 * 60 * 1000; -const mfaSetupPending = new Map(); -const MFA_BACKUP_CODE_COUNT = 10; - -const ADMIN_SETTINGS_KEYS = [ - 'allow_registration', 'allowed_file_types', 'require_mfa', - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', - 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', - 'notify_trip_reminder', - 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', - 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', -]; - -const avatarDir = path.join(__dirname, '../../uploads/avatars'); -if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); - -const KNOWN_COUNTRIES = new Set([ - 'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien', - 'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien', - 'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien', - 'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', - 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', - 'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen', - 'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien', - 'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien', - 'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland', - 'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen', - 'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia', - 'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru', - 'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate', - 'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong', - 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', - 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', - 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', - 'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien', - 'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei', - 'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait', - 'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien', - 'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika', - 'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch', - 'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland', -]); - -// --------------------------------------------------------------------------- -// Helpers (exported for route-level use where needed) -// --------------------------------------------------------------------------- - -export function utcSuffix(ts: string | null | undefined): string | null { - if (!ts) return null; - return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; -} - -export function stripUserForClient(user: User): Record { - const { - password_hash: _p, - maps_api_key: _m, - openweather_api_key: _o, - unsplash_api_key: _u, - mfa_secret: _mf, - mfa_backup_codes: _mbc, - ...rest - } = user; - return { - ...rest, - created_at: utcSuffix(rest.created_at), - updated_at: utcSuffix(rest.updated_at), - last_login: utcSuffix(rest.last_login), - mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), - must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true), - }; -} - -export function maskKey(key: string | null | undefined): string | null { - if (!key) return null; - if (key.length <= 8) return '--------'; - return '----' + key.slice(-4); -} - -export function mask_stored_api_key(key: string | null | undefined): string | null { - const plain = decrypt_api_key(key); - return maskKey(plain); -} - -export function resolveAuthToggles(): { - password_login: boolean; - password_registration: boolean; - oidc_login: boolean; - oidc_registration: boolean; - passkey_login: boolean; -} { - const get = (key: string) => - (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null; - - // 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 hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration'] - .some(k => get(k) !== 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', - passkey_login, - }; - if (readEnv().oidc.only) { - result.password_login = false; - result.password_registration = false; - } - return result; - } - - // Legacy fallback - const oidcOnlyEnabled = readEnv().oidc.only || get('oidc_only') === 'true'; - const oidcConfigured = !!( - (readEnv().oidc.issuer || get('oidc_issuer')) && - (readEnv().oidc.clientId || get('oidc_client_id')) - ); - const oidcOnly = oidcOnlyEnabled && oidcConfigured; - const allowReg = (get('allow_registration') ?? 'true') === 'true'; - - return { - password_login: !oidcOnly, - password_registration: !oidcOnly && allowReg, - oidc_login: true, - oidc_registration: allowReg, - passkey_login, - }; -} - -export function isOidcOnlyMode(): boolean { - return !resolveAuthToggles().password_login; -} - -export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) { - const pv = typeof user.password_version === 'number' - ? user.password_version - : ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 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 = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS; - return jwt.sign( - { id: user.id, pv }, - JWT_SECRET, - { expiresIn, algorithm: 'HS256' } - ); -} - -// --------------------------------------------------------------------------- -// MFA helpers -// --------------------------------------------------------------------------- - -export function normalizeBackupCode(input: string): string { - return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); -} - -// Legacy SHA-256 hex hash. Kept so existing stored hashes (from before -// the bcrypt migration) can still be verified in `matchBackupCode` -// without forcing every user to re-enrol their MFA device. New hashes -// are produced by `hashBackupCodeBcrypt` below. -export function hashBackupCode(input: string): string { - return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex'); -} - -const BCRYPT_BACKUP_COST = 10; - -/** - * Hash a backup code with bcrypt for at-rest storage. Backup codes only - * have ~40 bits of entropy (8 hex chars) so a plain SHA-256 rainbow - * table cracks them in minutes if the DB ever leaks. bcrypt with a - * moderate cost raises that cost by ~3-4 orders of magnitude. - */ -export function hashBackupCodeBcrypt(input: string): string { - return bcrypt.hashSync(normalizeBackupCode(input), BCRYPT_BACKUP_COST); -} - -/** - * Constant-time match of a plaintext backup code against a stored hash - * in either format (bcrypt or legacy SHA-256 hex). Used by login and - * password-reset flows; callers that need to CONSUME the matching - * entry should use this to find the index, then splice it out. - */ -export function matchBackupCode(plaintext: string, storedHash: string): boolean { - if (!storedHash) return false; - if (storedHash.startsWith('$2')) { - // bcrypt hash — compareSync is constant-time internally. - try { return bcrypt.compareSync(normalizeBackupCode(plaintext), storedHash); } - catch { return false; } - } - // Legacy SHA-256 hex. Compare the SHA-256 of the input against the - // stored hex with a constant-time comparator so timing can't leak. - const candidate = hashBackupCode(plaintext); - if (candidate.length !== storedHash.length) return false; - return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(storedHash)); -} - -export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] { - const codes: string[] = []; - while (codes.length < count) { - const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); - const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; - if (!codes.includes(code)) codes.push(code); - } - return codes; -} - -export function parseBackupCodeHashes(raw: string | null | undefined): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; - } catch { - return []; - } -} - -export function getPendingMfaSecret(userId: number): string | null { - const row = mfaSetupPending.get(userId); - if (!row || Date.now() > row.exp) { - mfaSetupPending.delete(userId); - return null; - } - return row.secret; -} - -// --------------------------------------------------------------------------- -// App config (public) -// --------------------------------------------------------------------------- - -export function getAppConfig(authenticatedUser: { id: number } | null) { - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; - const isDemo = readEnv().demo.enabled; - const toggles = resolveAuthToggles(); - const version: string = readEnv().app.appVersion ?? require('../../package.json').version; - const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get(); - const oidcDisplayName = readEnv().oidc.displayName || - (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; - const oidcConfigured = !!( - (readEnv().oidc.issuer || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && - (readEnv().oidc.clientId || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) - ); - const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; - const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none'; - const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value; - const hasSmtpHost = !!(readEnv().smtp.host || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value); - const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || 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 = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value; - const placesPhotosEnabled = placesPhotosSetting !== 'false'; - const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value; - const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false'; - const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value; - const placesDetailsEnabled = placesDetailsSetting !== 'false'; - const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); - - return { - // Legacy fields (backward compat) - allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration), - oidc_only_mode: !toggles.password_login && !toggles.password_registration, - // Granular toggles - password_login: toggles.password_login, - password_registration: isDemo ? false : toggles.password_registration, - oidc_login: toggles.oidc_login, - oidc_registration: isDemo ? false : toggles.oidc_registration, - // Passkey login: the instance toggle + whether a usable RP ID resolves for - // this deployment. The login page shows the passkey button only when both - // are true. `passkey_configured` stays a pure boolean — it never leaks the - // resolved RP ID / origin / APP_URL on this unauthenticated endpoint. - passkey_login: toggles.passkey_login, - passkey_configured: isPasskeyConfigured(), - env_override_oidc_only: readEnv().oidc.only, - has_users: userCount > 0, - setup_complete: setupComplete, - version, - is_prerelease: version.includes('-pre.'), - has_maps_key: hasGoogleKey, - oidc_configured: oidcConfigured, - oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, - require_mfa: requireMfaRow?.value === 'true', - allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', - demo_mode: isDemo, - demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined, - demo_password: isDemo ? 'demo12345' : undefined, - timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - notification_channel: notifChannel, - notification_channels: activeChannels, - available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, - trip_reminders_enabled: tripRemindersEnabled, - places_photos_enabled: placesPhotosEnabled, - places_autocomplete_enabled: placesAutocompleteEnabled, - places_details_enabled: placesDetailsEnabled, - permissions: authenticatedUser ? getAllPermissions() : undefined, - // Case-sensitive on purpose (legacy parity). - dev_mode: readEnv().app.nodeEnv === 'development', - }; -} - -// --------------------------------------------------------------------------- -// Auth: register, login, demo -// --------------------------------------------------------------------------- - -export function demoLogin(): { error?: string; status?: number; token?: string; user?: Record } { - if (!readEnv().demo.enabled) { - return { error: 'Not found', status: 404 }; - } - const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) as User | undefined; - if (!user) return { error: 'Demo user not found', status: 500 }; - const token = generateToken(user); - const safe = stripUserForClient(user) as Record; - return { token, user: { ...safe, avatar_url: avatarUrl(user) } }; -} - -export function validateInviteToken(token: string): { error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string } { - const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(token) as any; - 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 }; -} - -export function registerUser(body: { - username?: string; - email?: string; - password?: string; - invite_token?: string; -}): { error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record } { - const username = typeof body.username === 'string' ? body.username.trim() : ''; - const email = typeof body.email === 'string' ? body.email.trim() : ''; - const { password, invite_token } = body; - - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; - - let validInvite: any = null; - if (invite_token) { - validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(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 }; - } - - if (userCount > 0 && !validInvite) { - const toggles = resolveAuthToggles(); - if (!toggles.password_registration) { - return { error: 'Password registration is disabled. Contact your administrator.', status: 403 }; - } - } - - if (!username || !email || !password) { - return { error: 'Username, email and password are required', status: 400 }; - } - - const pwCheck = validatePassword(password); - if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; - - if (!EMAIL_REGEX.test(email)) { - return { error: 'Invalid email format', status: 400 }; - } - - // Ignore guests (#1362): their synthetic username/email must never block a real signup. - const existingUser = db.prepare('SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) AND COALESCE(is_guest, 0) = 0').get(email, username); - if (existingUser) { - return { error: 'Registration failed. Please try different credentials.', status: 409 }; - } - - const password_hash = bcrypt.hashSync(password, BCRYPT_COST); - const isFirstUser = userCount === 0; - const role = isFirstUser ? 'admin' : 'user'; - - try { - const result = db.prepare( - 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)' - ).run(username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'); - - const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; - const token = generateToken(user); - - if (validInvite) { - const updated = db.prepare( - 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count' - ).get(validInvite.id); - 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) { - joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null); - } - } - - return { - token, - user: { ...user, avatar_url: null }, - auditUserId: Number(result.lastInsertRowid), - auditDetails: { username, email, role }, - }; - } catch { - return { error: 'Error creating user', status: 500 }; - } -} - -export function loginUser(body: { - email?: string; - password?: string; - remember_me?: boolean; -}): { - error?: string; - status?: number; - token?: string; - user?: Record; - mfa_required?: boolean; - mfa_token?: string; - remember?: boolean; - auditUserId?: number | null; - auditAction?: string; - auditDetails?: Record; -} { - if (isOidcOnlyMode()) { - return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; - } - - const { email, password, remember_me } = body; - const remember = remember_me === true; - if (!email || !password) { - return { error: 'Email and password are required', status: 400 }; - } - - // 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 = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0').get(email) as User | undefined; - - // 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). - const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH; - const validPassword = bcrypt.compareSync(password, hashToCheck); - - if (!user) { - return { - error: 'Invalid email or password', status: 401, - auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' }, - }; - } - if (!user.password_hash) { - return { - error: 'Invalid email or password', status: 401, - auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' }, - }; - } - if (!validPassword) { - return { - error: 'Invalid email or password', status: 401, - auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'wrong_password' }, - }; - } - - if (user.mfa_enabled === 1 || user.mfa_enabled === true) { - const pv = (user as User & { password_version?: number }).password_version ?? 0; - const mfa_token = jwt.sign( - { id: Number(user.id), purpose: 'mfa_login', pv }, - JWT_SECRET, - { expiresIn: '5m', algorithm: 'HS256' } - ); - return { mfa_required: true, mfa_token }; - } - - db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); - const token = generateToken(user, remember); - const userSafe = stripUserForClient(user) as Record; - - return { - token, - user: { ...userSafe, avatar_url: avatarUrl(user) }, - remember, - auditUserId: Number(user.id), - auditAction: 'user.login', - auditDetails: { email }, - }; -} - -// --------------------------------------------------------------------------- -// Session -// --------------------------------------------------------------------------- - -export function getCurrentUser( - userId: number -): (Record & Pick & { avatar_url: string }) | null { - const user = db.prepare( - 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?' - ).get(userId) as User | undefined; - if (!user) return null; - const base = stripUserForClient(user as User) as Record; - return { ...base, id: user.id, username: user.username, email: user.email, role: user.role, avatar_url: avatarUrl(user) }; -} - -// --------------------------------------------------------------------------- -// Password & account -// --------------------------------------------------------------------------- - -export function changePassword( - userId: number, - userEmail: string, - body: { current_password?: string; new_password?: string } -): { error?: string; status?: number; success?: boolean; token?: string } { - if (isOidcOnlyMode()) { - return { error: 'Password authentication is disabled.', status: 403 }; - } - if (readEnv().demo.enabled && isDemoEmail(userEmail)) { - return { error: 'Password change is disabled in demo mode.', status: 403 }; - } - - const { current_password, new_password } = body; - if (!current_password) return { error: 'Current password is required', status: 400 }; - if (!new_password) return { error: 'New password is required', status: 400 }; - - const pwCheck = validatePassword(new_password); - if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; - - const user = db.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(userId) as { password_hash: string; password_version?: number } | undefined; - if (!user || !bcrypt.compareSync(current_password, user.password_hash)) { - return { error: 'Current password is incorrect', status: 401 }; - } - - const hash = bcrypt.hashSync(new_password, BCRYPT_COST); - const newPv = (user.password_version ?? 0) + 1; - - db.transaction(() => { - db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, newPv, userId); - // 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). - db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(userId); - try { - db.prepare("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId); - } catch { /* oauth_tokens table may not exist in very old installs */ } - })(); - - try { revokeUserSessions?.(userId); } catch { /* best-effort */ } - - // Re-issue a session bound to the new password_version so the current device - // stays logged in while other existing sessions are rotated out by the pv gate. - const token = generateToken({ id: userId, password_version: newPv }); - return { success: true, token }; -} - -export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } { - if (readEnv().demo.enabled && isDemoEmail(userEmail)) { - return { error: 'Account deletion is disabled in demo mode.', status: 403 }; - } - if (userRole === 'admin') { - const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count; - if (adminCount <= 1) { - return { error: 'Cannot delete the last admin account', status: 400 }; - } - } - deleteUserCompletely(userId); - emitUserDeleted(userId); // let plugins erase their own per-user data - return { success: true }; -} - -// --------------------------------------------------------------------------- -// API keys -// --------------------------------------------------------------------------- - -export function updateMapsKey(userId: number, maps_api_key: string | null | undefined) { - db.prepare( - 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run(maybe_encrypt_api_key(maps_api_key), userId); - return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) }; -} - -export function updateApiKeys( - userId: number, - body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string } -) { - const current = db.prepare('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?').get(userId) as Pick | undefined; - - db.prepare( - 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run( - body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key, - body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key, - body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current!.unsplash_api_key, - userId - ); - - const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?' - ).get(userId) as Pick | undefined; - - const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - return { - success: true, - user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, - }; -} - -export function updateSettings( - userId: number, - body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string } -): { error?: string; status?: number; success?: boolean; user?: Record } { - const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body; - - if (username !== undefined) { - const trimmed = username.trim(); - if (!trimmed || trimmed.length < 2 || trimmed.length > 50) { - return { error: 'Username must be between 2 and 50 characters', status: 400 }; - } - if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) { - return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 }; - } - const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0').get(trimmed, userId); - if (conflict) return { error: 'Username already taken', status: 409 }; - } - - if (email !== undefined) { - const trimmed = email.trim(); - if (!trimmed || !EMAIL_REGEX.test(trimmed)) { - return { error: 'Invalid email format', status: 400 }; - } - const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0').get(trimmed, userId); - if (conflict) return { error: 'Email already taken', status: 409 }; - } - - const updates: string[] = []; - const params: (string | number | null)[] = []; - - if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } - if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } - if (unsplash_api_key !== undefined) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); } - if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); } - if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); } - - if (updates.length > 0) { - updates.push('updated_at = CURRENT_TIMESTAMP'); - params.push(userId); - db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params); - } - - const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?' - ).get(userId) as Pick | undefined; - - const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - return { - success: true, - user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, - }; -} - -export function getSettings(userId: number): { error?: string; status?: number; settings?: Record } { - const user = db.prepare( - 'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?' - ).get(userId) as Pick | undefined; - if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; - - return { - settings: { - maps_api_key: decrypt_api_key(user.maps_api_key), - openweather_api_key: decrypt_api_key(user.openweather_api_key), - unsplash_api_key: decrypt_api_key(user.unsplash_api_key), - }, - }; -} - -// --------------------------------------------------------------------------- -// Avatar -// --------------------------------------------------------------------------- - -export async function saveAvatar(userId: number, filename: string) { - const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined; - // Only a locally uploaded file has something to clean up. An OIDC picture URL - // (#1399) has no file on disk, so skip the rm — path.join on a URL is meaningless. - if (current?.avatar && !/^https:\/\//i.test(current.avatar)) { - // Fire-and-forget: leftover files are harmless; the DB update is - // the source of truth for which avatar is current. - const oldPath = path.join(avatarDir, current.avatar); - await fs.promises.rm(oldPath, { force: true }).catch(() => {}); - } - - db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId); - - const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(userId) as Pick | undefined; - return { success: true, avatar_url: avatarUrl(updated || {}) }; -} - -export async function deleteAvatar(userId: number) { - const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined; - // An OIDC picture URL (#1399) has no local file — only rm an uploaded one. - if (current?.avatar && !/^https:\/\//i.test(current.avatar)) { - const filePath = path.join(avatarDir, current.avatar); - await fs.promises.rm(filePath, { force: true }).catch(() => {}); - } - db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId); - return { success: true }; -} - -// --------------------------------------------------------------------------- -// User directory -// --------------------------------------------------------------------------- - -export function 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 = db.prepare( - 'SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC' - ).all(excludeUserId) as Pick[]; - return users.map(u => ({ ...u, avatar_url: avatarUrl(u) })); -} - -// --------------------------------------------------------------------------- -// Key validation -// --------------------------------------------------------------------------- - -export async function 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 = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick | undefined; - if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null }; - - const result: { - 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; - }; - } = { maps: false, weather: false, maps_details: null }; - - const maps_api_key = decrypt_api_key(user.maps_api_key); - if (maps_api_key) { - try { - const mapsRes = await fetch( - `https://places.googleapis.com/v1/places:searchText`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': maps_api_key, - 'X-Goog-FieldMask': 'places.displayName', - }, - body: JSON.stringify({ textQuery: 'test' }), - } - ); - result.maps = mapsRes.status === 200; - let error_text: string | null = null; - let error_json: any = null; - if (!result.maps) { - try { - error_text = await mapsRes.text(); - try { error_json = JSON.parse(error_text); } catch { error_json = null; } - } catch { error_text = null; error_json = null; } - } - result.maps_details = { - ok: result.maps, - status: mapsRes.status, - status_text: mapsRes.statusText || null, - error_message: error_json?.error?.message || null, - error_status: error_json?.error?.status || null, - error_raw: error_text, - }; - } catch (err: unknown) { - result.maps = false; - result.maps_details = { - ok: false, - status: null, - status_text: null, - error_message: err instanceof Error ? err.message : 'Request failed', - error_status: 'FETCH_ERROR', - error_raw: null, - }; - } - } - - const openweather_api_key = decrypt_api_key(user.openweather_api_key); - if (openweather_api_key) { - try { - const weatherRes = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` - ); - result.weather = weatherRes.status === 200; - } catch { - result.weather = false; - } - } - - return result; -} - -// --------------------------------------------------------------------------- -// Admin settings -// --------------------------------------------------------------------------- - -export function getAppSettings(userId: number): { error?: string; status?: number; data?: Record } { - const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined; - if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; - - const result: Record = {}; - for (const key of ADMIN_SETTINGS_KEYS) { - const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; - if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value; - } - return { data: result }; -} - -export function updateAppSettings( - userId: number, - body: Record -): { - error?: string; - status?: number; - success?: boolean; - auditSummary?: Record; - auditDebugDetails?: Record; - shouldRestartScheduler?: boolean; -} { - const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined; - if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; - - const { require_mfa } = body; - if (require_mfa === true || require_mfa === 'true') { - const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; - // 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 = !!db.prepare('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1').get(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.', - status: 400, - }; - } - } - - // Lockout prevention: can't disable all login methods - if (body.password_login !== undefined || body.oidc_login !== undefined) { - const current = resolveAuthToggles(); - const oidcConfigured = !!( - (readEnv().oidc.issuer || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && - (readEnv().oidc.clientId || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) - ); - 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; - if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) { - return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 }; - } - } - - for (const key of ADMIN_SETTINGS_KEYS) { - if (body[key] !== undefined) { - let val = String(body[key]); - if (key === 'require_mfa') { - val = body[key] === true || val === 'true' ? 'true' : 'false'; - } - if (key === 'smtp_pass' && val === '••••••••') continue; - if (key === 'smtp_pass') val = encrypt_api_key(val); - if (key === 'admin_webhook_url' && val === '••••••••') continue; - 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; - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); - } - } - - const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); - - const summary: Record = {}; - const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); - if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels; - if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true; - if (changedKeys.some(k => k.startsWith('admin_ntfy_'))) summary.admin_ntfy_updated = true; - if (smtpChanged) summary.smtp_settings_updated = true; - if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration; - if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; - if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa; - - const debugDetails: Record = {}; - for (const k of changedKeys) { - debugDetails[k] = k === 'smtp_pass' ? '***' : body[k]; - } - - const notifRelated = ['notification_channels', 'smtp_host']; - const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k)); - if (shouldRestartScheduler) { - startTripReminders(); - } - - return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler }; -} - -// --------------------------------------------------------------------------- -// Travel stats -// --------------------------------------------------------------------------- - -export function getTravelStats(userId: number) { - const places = db.prepare(` - SELECT DISTINCT p.address, p.lat, p.lng - FROM places p - JOIN trips t ON p.trip_id = t.id - LEFT JOIN trip_members tm ON t.id = tm.trip_id - WHERE t.user_id = ? OR tm.user_id = ? - `).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[]; - - // Archived trips still count here, matching the places, countries and flight - // distance widgets (which never filtered on is_archived) so the dashboard stats - // stay consistent — archiving a trip no longer zeroes out trips/days. - const tripStats = db.prepare(` - SELECT COUNT(DISTINCT t.id) as trips, - COUNT(DISTINCT d.id) as days - FROM trips t - LEFT JOIN days d ON d.trip_id = t.id - LEFT JOIN trip_members tm ON t.id = tm.trip_id - WHERE (t.user_id = ? OR tm.user_id = ?) - `).get(userId, userId) as { trips: number; days: number } | undefined; - - const cities = new Set(); - const coords: { lat: number; lng: number }[] = []; - - places.forEach(p => { - if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng }); - if (p.address) { - const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); - const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); - if (cityPart) cities.add(cityPart); - } - }); - - // Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from - // auto-resolved place regions plus countries the user marked manually. - const countryCodes = new Set(); - const manualCountries = db.prepare( - 'SELECT country_code FROM visited_countries WHERE user_id = ?' - ).all(userId) as { country_code: string }[]; - manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); }); - - const placeRegionCodes = db.prepare(` - SELECT DISTINCT pr.country_code - FROM place_regions pr - JOIN places p ON p.id = pr.place_id - JOIN trips t ON p.trip_id = t.id - LEFT JOIN trip_members tm ON t.id = tm.trip_id - WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL - `).all(userId, userId) as { country_code: string }[]; - placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); }); - - // Transport bookings don't create a place row, so their geocoded endpoints never - // reached place_regions — a country reached only by a flight/train (no lodging or - // planned place there) was never counted as visited (#1366). Resolve each endpoint - // coordinate to a country and fold it in too. - // Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate - // connection/layover (e.g. a plane change) the traveler never really visited (#1486). - const endpoints = db.prepare(` - SELECT DISTINCT e.lat, e.lng - FROM reservation_endpoints e - JOIN reservations r ON e.reservation_id = r.id - JOIN trips t ON r.trip_id = t.id - LEFT JOIN trip_members tm ON t.id = tm.trip_id - WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to') - `).all(userId, userId) as { lat: number; lng: number }[]; - for (const e of endpoints) { - const code = getCountryFromCoords(e.lat, e.lng); - if (code) countryCodes.add(code.toUpperCase()); - } - - // Countries the user removed in Atlas stay removed on the dashboard too, so the - // passport card and the Atlas map agree (#1490). - for (const code of getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase()); - - return { - countries: [...countryCodes], - cities: [...cities], - coords, - totalTrips: tripStats?.trips || 0, - totalDays: tripStats?.days || 0, - totalPlaces: places.length, - totalDistanceKm: getFlightDistanceKm(userId), - }; -} - -// --------------------------------------------------------------------------- -// MFA -// --------------------------------------------------------------------------- - -export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise } { - if (readEnv().demo.enabled && isDemoEmail(userEmail)) { - return { error: 'MFA is not available in demo mode.', status: 403 }; - } - const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; - if (row?.mfa_enabled) { - return { error: 'MFA is already enabled', status: 400 }; - } - let secret: string, otpauth_url: string; - try { - secret = authenticator.generateSecret(); - mfaSetupPending.set(userId, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); - otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret); - } catch (err) { - console.error('[MFA] Setup error:', err); - return { error: 'MFA setup failed', status: 500 }; - } - return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) }; -} - -export function enableMfa(userId: number, code?: string): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } { - if (!code) { - return { error: 'Verification code is required', status: 400 }; - } - const pending = getPendingMfaSecret(userId); - if (!pending) { - return { error: 'No MFA setup in progress. Start the setup again.', status: 400 }; - } - const tokenStr = String(code).replace(/\s/g, ''); - const ok = authenticator.verify({ token: tokenStr, secret: pending }); - if (!ok) { - return { error: 'Invalid verification code', status: 401 }; - } - const backupCodes = generateBackupCodes(); - const backupHashes = backupCodes.map(hashBackupCodeBcrypt); - const enc = encryptMfaSecret(pending); - db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - enc, - JSON.stringify(backupHashes), - userId - ); - mfaSetupPending.delete(userId); - return { success: true, mfa_enabled: true, backup_codes: backupCodes }; -} - -export function disableMfa( - userId: number, - userEmail: string, - body: { password?: string; code?: string } -): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } { - if (readEnv().demo.enabled && isDemoEmail(userEmail)) { - return { error: 'MFA cannot be changed in demo mode.', status: 403 }; - } - const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; - if (policy?.value === '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 = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined; - if (!user?.mfa_enabled || !user.mfa_secret) { - return { error: 'MFA is not enabled', status: 400 }; - } - if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { - return { error: 'Incorrect password', status: 401 }; - } - const secret = decryptMfaSecret(user.mfa_secret); - const tokenStr = String(code).replace(/\s/g, ''); - const ok = authenticator.verify({ token: tokenStr, secret }); - if (!ok) { - return { error: 'Invalid verification code', status: 401 }; - } - db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - userId - ); - mfaSetupPending.delete(userId); - return { success: true, mfa_enabled: false }; -} - -export function verifyMfaLogin(body: { - mfa_token?: string; - code?: string; - remember_me?: boolean; -}): { - error?: string; - status?: number; - token?: string; - user?: Record; - remember?: boolean; - auditUserId?: number; -} { - const { mfa_token, code, remember_me } = body; - const remember = remember_me === true; - if (!mfa_token || !code) { - return { error: 'Verification token and code are required', status: 400 }; - } - try { - const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string }; - if (decoded.purpose !== 'mfa_login') { - return { error: 'Invalid verification token', status: 401 }; - } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined; - if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { - return { error: 'Invalid session', status: 401 }; - } - const secret = decryptMfaSecret(user.mfa_secret); - const tokenStr = String(code).trim(); - const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret }); - if (!okTotp) { - const hashes = parseBackupCodeHashes(user.mfa_backup_codes); - // matchBackupCode handles both bcrypt and legacy SHA-256 hashes; - // any store older than the bcrypt migration keeps working. - const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h)); - if (idx === -1) { - return { error: 'Invalid verification code', status: 401 }; - } - hashes.splice(idx, 1); - db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - JSON.stringify(hashes), - user.id - ); - } - db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); - const sessionToken = generateToken(user, remember); - const userSafe = stripUserForClient(user) as Record; - return { - token: sessionToken, - user: { ...userSafe, avatar_url: avatarUrl(user) }, - remember, - auditUserId: Number(user.id), - }; - } catch { - return { error: 'Invalid or expired verification token', status: 401 }; - } -} - -// --------------------------------------------------------------------------- -// Password reset -// --------------------------------------------------------------------------- - -// 60 min; long enough to read the email in a second tab, short enough -// that a leaked link is unlikely to still be valid when someone tries it. -const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000; -const PASSWORD_RESET_TOKEN_BYTES = 32; // 256-bit entropy - -/** - * Returns the SHA-256 hex hash of a reset token. Raw tokens are never - * persisted — we only store and compare their hashes. - */ -function hashResetToken(raw: string): string { - return createHash('sha256').update(raw).digest('hex'); -} - -/** - * Shape returned by requestPasswordReset. For enumeration-safety the - * route ALWAYS returns the same response to the client regardless of - * whether a user existed — this struct is only consumed internally by - * the route handler to decide whether to send an email / log a link. - */ -export interface PasswordResetRequestOutcome { - tokenForDelivery: string | null; // raw token — send via email or log, never return to client - userId: number | null; - userEmail: string | null; - reason: 'issued' | 'no_user' | 'oidc_only' | 'throttled_per_email' | 'password_login_disabled'; -} - -// Per-email throttle (defence-in-depth on top of the per-IP limiter). -const perEmailResetAttempts = new Map(); -const PASSWORD_RESET_PER_EMAIL_WINDOW_MS = 15 * 60 * 1000; -const PASSWORD_RESET_PER_EMAIL_MAX = 3; -setInterval(() => { - const now = Date.now(); - for (const [key, record] of perEmailResetAttempts) { - if (now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) perEmailResetAttempts.delete(key); - } -}, 5 * 60 * 1000).unref?.(); - -export function requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome { - const email = String(rawEmail || '').trim().toLowerCase(); - // Basic shape check — a fully empty / malformed email is treated like - // "no user" so we still spend the same time internally. - const looksLikeEmail = email.length > 0 && /.+@.+\..+/.test(email); - - // Global policy check: password login disabled → no reset possible. - const toggles = resolveAuthToggles(); - if (!toggles.password_login) { - return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' }; - } - - // Per-email throttle. We check this BEFORE the DB lookup so the timing - // is identical regardless of whether the account exists. - const throttleKey = email || '__noemail__'; - const now = Date.now(); - const record = perEmailResetAttempts.get(throttleKey); - if (record && record.count >= PASSWORD_RESET_PER_EMAIL_MAX && now - record.first < PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { - return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'throttled_per_email' }; - } - if (!record || now - record.first >= PASSWORD_RESET_PER_EMAIL_WINDOW_MS) { - perEmailResetAttempts.set(throttleKey, { count: 1, first: now }); - } else { - record.count++; - } - - if (!looksLikeEmail) { - return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; - } - - // A guest (#1362) must never receive a reset link — treat its synthetic email as unknown. - const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email) as - | { id: number; email: string; password_hash: string | null; oidc_sub: string | null } - | undefined; - - if (!user) { - return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; - } - // SSO-linked account — refuse a reset. OIDC users are created with a random - // bcrypt hash (so password_hash is never empty), which is why we must key off - // oidc_sub rather than a missing hash. Letting the reset proceed would set a - // local password and revoke session/credential state, which breaks the SSO - // login; admins (or the user, with their current password) can still set one. - // The client still gets the generic "if that email exists…" response. - if (user.oidc_sub) { - return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' }; - } - - // Invalidate any prior unconsumed tokens for this user so there is - // always at most one live reset link in flight. - db.prepare( - "UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL" - ).run(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(); - - db.prepare( - 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)' - ).run(user.id, token_hash, expires_at, createdIp); - - return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' }; -} - -export interface ResetPasswordOutcome { - error?: string; - status?: number; - success?: boolean; - /** When true the client must collect a TOTP/backup code and call again. */ - mfa_required?: boolean; - userId?: number; -} - -/** - * Consume a reset token and set a new password. If the target user has - * MFA enabled, a valid TOTP code or backup code must be supplied — a - * compromised email alone therefore does NOT allow taking over a - * 2FA-protected account. - */ -export function resetPassword(body: { - token?: string; - new_password?: string; - mfa_code?: string; -}): ResetPasswordOutcome { - const { token, new_password, mfa_code } = body; - if (!token || typeof token !== 'string') { - return { error: 'Reset token is required', status: 400 }; - } - if (!new_password || typeof new_password !== 'string') { - return { error: 'New password is required', status: 400 }; - } - // Check the policy BEFORE touching the token so an invalid password - // does not burn the user's one-time link. - const pwCheck = validatePassword(new_password); - if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 }; - - const tokenHash = hashResetToken(token); - const row = db.prepare( - 'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?' - ).get(tokenHash) as - | { id: number; user_id: number; expires_at: string; consumed_at: string | null } - | undefined; - - 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 }; - if (new Date(row.expires_at).getTime() < Date.now()) { - return { error: 'Reset link has expired. Please request a new one.', status: 400 }; - } - - const user = db.prepare( - 'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?' - ).get(row.user_id) as - | { id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number } - | undefined; - - 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; - let backupCodeConsumedIndex: number | null = null; - if (mfaOn) { - if (!user.mfa_secret) { - // Data inconsistency — fail closed. - return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 }; - } - const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : ''; - if (!supplied) return { mfa_required: true, status: 200 }; - - const secret = decryptMfaSecret(user.mfa_secret); - const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret }); - if (!okTotp) { - const hashes = parseBackupCodeHashes(user.mfa_backup_codes); - const idx = hashes.findIndex((h) => matchBackupCode(supplied, h)); - if (idx === -1) return { error: 'Invalid MFA code', status: 401 }; - backupCodeConsumedIndex = idx; - } - } - - const newHash = bcrypt.hashSync(new_password, BCRYPT_COST); - const newPv = (user.password_version ?? 0) + 1; - - db.transaction(() => { - // Burn the token first to keep it atomic with the password change. - db.prepare('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id); - // Also burn every OTHER live token for this user — a fresh login - // should not leave a second door open. - db.prepare( - "UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?" - ).run(user.id, row.id); - db.prepare( - 'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run(newHash, newPv, user.id); - // Consume backup code if one was used. - if (backupCodeConsumedIndex !== null) { - const hashes = parseBackupCodeHashes(user.mfa_backup_codes); - hashes.splice(backupCodeConsumedIndex, 1); - db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id); - } - // 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. - db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(user.id); - try { - db.prepare( - "UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL" - ).run(user.id); - } catch { /* oauth_tokens table may not exist in very old installs */ } - })(); - - // Kick off any MCP/WS session cleanup — same hook the account-delete path uses. - try { revokeUserSessions?.(user.id); } catch { /* best-effort */ } - - return { success: true, userId: user.id }; -} - -// --------------------------------------------------------------------------- -// MCP tokens -// --------------------------------------------------------------------------- - -export function listMcpTokens(userId: number) { - return db.prepare( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC' - ).all(userId); -} - -export function createMcpToken(userId: number, name?: string): { error?: string; status?: number; token?: Record } { - if (!name?.trim()) return { error: 'Token name is required', status: 400 }; - if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; - - const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(userId) as { count: number }).count; - if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 }; - - const rawToken = 'trek_' + randomBytes(24).toString('hex'); - const tokenHash = createHash('sha256').update(rawToken).digest('hex'); - const tokenPrefix = rawToken.slice(0, 13); - - const result = db.prepare( - 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' - ).run(userId, name.trim(), tokenHash, tokenPrefix); - - const token = db.prepare( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?' - ).get(result.lastInsertRowid); - - return { token: { ...(token as object), raw_token: rawToken } }; -} - -export function deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } { - const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(tokenId, userId); - if (!token) return { error: 'Token not found', status: 404 }; - db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(tokenId); - revokeUserSessions(userId); - return { success: true }; -} - -// --------------------------------------------------------------------------- -// Ephemeral tokens -// --------------------------------------------------------------------------- - -export function createWsToken(userId: number): { error?: string; status?: number; token?: string } { - // Bind the ws-token to the user's current password_version so a token minted - // before a password reset is rejected on connect (defence-in-depth session gate). - const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(userId) as { password_version?: number } | undefined)?.password_version ?? 0; - const token = createEphemeralToken(userId, 'ws', { pv }); - if (!token) return { error: 'Service unavailable', status: 503 }; - return { token }; -} - -export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } { - if (purpose !== 'download') { - return { error: 'Invalid purpose', status: 400 }; - } - const token = createEphemeralToken(userId, purpose); - if (!token) return { error: 'Service unavailable', status: 503 }; - return { token }; -} - -// --------------------------------------------------------------------------- -// MCP auth helpers -// --------------------------------------------------------------------------- - -export function isDemoUser(userId: number): boolean { - if (!readEnv().demo.enabled) return false; - const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; - return isDemoEmail(user?.email); -} - -export function verifyMcpToken(rawToken: string): User | null { - const hash = createHash('sha256').update(rawToken).digest('hex'); - const row = db.prepare(` - SELECT u.id, u.username, u.email, u.role - FROM mcp_tokens mt - JOIN users u ON mt.user_id = u.id - WHERE mt.token_hash = ? - `).get(hash) as User | undefined; - if (row) { - db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); - return row; - } - return null; -} - -/** - * Verify a JWT the same way `middleware/auth.ts#verifyJwtAndLoadUser` - * does — including the `password_version` check — so that stolen tokens - * lose access the moment the victim resets their password. - * - * This is the single entry point every non-cookie JWT verification path - * (MCP bearer, WebSocket handshake, file-download query tokens, photo - * route) should go through. - */ -export function verifyJwtToken(token: string): User | null { - return verifyJwtAndLoadUser(token); -} diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 8fbdcc474..5c2b963cb 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -5,7 +5,7 @@ import { db } from '../db/database'; import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { User } from '../types'; import { decrypt_api_key } from './apiKeyCrypto'; -import { resolveAuthToggles } from './authService'; +import { resolveAuthToggles } from '../nest/auth/auth.bridge'; import { joinTripAsMember } from './tripMembership'; // --------------------------------------------------------------------------- diff --git a/server/src/services/passkeyService.ts b/server/src/services/passkeyService.ts index 7c8c25487..a5ec4abfc 100644 --- a/server/src/services/passkeyService.ts +++ b/server/src/services/passkeyService.ts @@ -8,7 +8,7 @@ import { } from '@simplewebauthn/server'; import { db } from '../db/database'; import { resolveWebauthnConfig } from './webauthnConfig'; -import { generateToken, stripUserForClient, avatarUrl } from './authService'; +import { generateToken, stripUserForClient, avatarUrl } from '../nest/auth/auth.bridge'; import type { User } from '../types'; // --------------------------------------------------------------------------- diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index 959eb7d31..b2620c493 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -1,35 +1,39 @@ /** * Auth e2e — exercises the migrated /api/auth endpoints through the real - * JwtAuthGuard/OptionalJwtGuard AND the real cookie service against a temp - * SQLite db. Only the authService (credential/MFA logic) + audit/notifications - * are mocked; this proves the httpOnly trek_session cookie is set on login and - * cleared on logout, that /me requires a session, and that /app-config is - * optional-auth. + * JwtAuthGuard/OptionalJwtGuard, the real cookie service AND the real + * DI-native AuthService (DatabaseModule + AuthModule, which pulls in + * Audit/Permissions/AtlasModule) against a temp SQLite db (full schema). + * No service mock (the legacy path-mock died with the auth fold): login runs + * real bcrypt against a factory-seeded hash, audit rows land in audit_log for + * real, and the httpOnly trek_session cookie set/clear is asserted end to end. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; -import { seedUser, sessionCookie } from './harness'; +import { sessionCookie } from './harness'; const { db } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); - tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); - // AuditService now runs its real INSERT (DI-injected, no mock) — slim - // audit_log mirror (no FKs), same shape as plugin-runtime.test.ts. - tmp.exec(`CREATE TABLE audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`); + tmp.exec('PRAGMA foreign_keys = ON'); return { db: tmp }; }); -vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -// The audit domain is DI-native now: writeAudit runs for real against the temp +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, +})); + +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +// The audit domain is DI-native: writeAudit runs for real against the temp // db's audit_log table; only the file logger is silenced. vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) })); @@ -38,81 +42,95 @@ vi.mock('../../src/app-config', async (importOriginal) => { return { ...actual, getAppUrl: () => 'https://x' }; }); -const { authSvc } = vi.hoisted(() => ({ - authSvc: { - getAppConfig: vi.fn(), demoLogin: vi.fn(), validateInviteToken: vi.fn(), registerUser: vi.fn(), loginUser: vi.fn(), - requestPasswordReset: vi.fn(), resetPassword: vi.fn(), verifyMfaLogin: vi.fn(), getCurrentUser: vi.fn(), - changePassword: vi.fn(), deleteAccount: vi.fn(), updateMapsKey: vi.fn(), updateApiKeys: vi.fn(), updateSettings: vi.fn(), - getSettings: vi.fn(), saveAvatar: vi.fn(), deleteAvatar: vi.fn(), listUsers: vi.fn(), validateKeys: vi.fn(), - getAppSettings: vi.fn(), updateAppSettings: vi.fn(), getTravelStats: vi.fn(), setupMfa: vi.fn(), enableMfa: vi.fn(), - disableMfa: vi.fn(), listMcpTokens: vi.fn(), createMcpToken: vi.fn(), deleteMcpToken: vi.fn(), createWsToken: vi.fn(), - createResourceToken: vi.fn(), - }, -})); -vi.mock('../../src/services/authService', () => authSvc); - +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser } from '../helpers/factories'; import { AuthModule } from '../../src/nest/auth/auth.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => { +describe('Auth e2e (real auth guard + real service + real cookie service + temp SQLite)', () => { let server: Server; let app: Awaited>; + let userId: number; + let userEmail: string; + let userPassword: string; async function build() { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); + // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate + // by metatype, exactly as they do under buildApp(). + nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } + const auditRows = (action: string) => + (db.prepare('SELECT COUNT(*) AS n FROM audit_log WHERE action = ?').get(action) as { n: number }).n; + beforeAll(async () => { - seedUser(db as never, { id: 1, email: 'u@example.test' }); + createTables(db as never); + runMigrations(db as never); + const seeded = createUser(db as never, { username: 'auth-e2e', email: 'u@example.test' }); + userId = seeded.user.id; + userEmail = seeded.user.email; + userPassword = seeded.password; app = await build(); server = app.getHttpServer(); - authSvc.getAppConfig.mockReturnValue({ version: '3' }); - authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } }); - authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' }); }); - beforeEach(() => vi.clearAllMocks()); - afterAll(async () => { await app.close(); }); - it('GET /app-config is optional-auth (200 without a cookie)', async () => { - authSvc.getAppConfig.mockReturnValue({ version: '3' }); + it('GET /app-config is optional-auth (200 without a cookie, real toggles)', async () => { const res = await request(server).get('/api/auth/app-config'); expect(res.status).toBe(200); - expect(res.body).toEqual({ version: '3' }); + expect(res.body.password_login).toBe(true); + expect(res.body.has_users).toBe(true); + expect(typeof res.body.version).toBe('string'); + expect(res.body.version.length).toBeGreaterThan(0); + // Unauthenticated → no permissions block. + expect(res.body.permissions).toBeUndefined(); }); it('GET /me requires a session (401 without a cookie)', async () => { expect((await request(server).get('/api/auth/me')).status).toBe(401); }); - it('GET /me returns the user with a valid session', async () => { - authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' }); - const res = await request(server).get('/api/auth/me').set('Cookie', sessionCookie(1)); + it('GET /me returns the real user row with a valid session', async () => { + const res = await request(server).get('/api/auth/me').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); - expect(res.body).toEqual({ user: { id: 1, email: 'u@example.test' } }); + expect(res.body.user.id).toBe(userId); + expect(res.body.user.email).toBe(userEmail); + expect(res.body.user).not.toHaveProperty('password_hash'); }); - it('POST /login sets the httpOnly trek_session cookie', async () => { - authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } }); - const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' }); + it('POST /login sets the httpOnly trek_session cookie and audits user.login', async () => { + const before = auditRows('user.login'); + const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword }); expect(res.status).toBe(200); - expect(res.body).toEqual({ token: 'jwt.token.value', user: { id: 1 } }); + expect(typeof res.body.token).toBe('string'); + expect(res.body.user.id).toBe(userId); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); + expect(auditRows('user.login')).toBe(before + 1); + }, 10000); + + it('POST /login with a wrong password answers the generic 401 and audits user.login_failed', async () => { + const before = auditRows('user.login_failed'); + const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: 'wrong-password' }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'Invalid email or password' }); + expect(auditRows('user.login_failed')).toBe(before + 1); }, 10000); it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => { - authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true }); - const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true }); + const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword, remember_me: true }); expect(res.status).toBe(200); const setCookie = res.headers['set-cookie'] as unknown as string[]; const cookie = setCookie.find((c) => c.startsWith('trek_session='))!; @@ -123,8 +141,7 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => }, 10000); it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => { - authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false }); - const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' }); + const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword }); expect(res.status).toBe(200); const setCookie = res.headers['set-cookie'] as unknown as string[]; const cookie = setCookie.find((c) => c.startsWith('trek_session='))!; @@ -132,6 +149,28 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => expect(cookie).not.toMatch(/Expires/i); }, 10000); + it('POST /register creates the user, sets the cookie and audits user.register', async () => { + const before = auditRows('user.register'); + const res = await request(server) + .post('/api/auth/register') + .send({ username: 'fresh-e2e', email: 'fresh-e2e@example.test', password: 'Str0ng!Pass1' }); + expect(res.status).toBe(201); + expect(typeof res.body.token).toBe('string'); + expect(res.body.user.username).toBe('fresh-e2e'); + const setCookie = res.headers['set-cookie'] as unknown as string[]; + expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); + expect(auditRows('user.register')).toBe(before + 1); + const row = db.prepare('SELECT id FROM users WHERE email = ?').get('fresh-e2e@example.test'); + expect(row).toBeDefined(); + }); + + it('POST /login with a shapeless body answers the pipe 400 envelope', async () => { + const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test' }); + expect(res.status).toBe(400); + // The global ZodValidationPipe envelope: { error: 'field: message; ...' }. + expect(res.body.error).toMatch(/password/i); + }); + it('POST /logout clears the session cookie', async () => { const res = await request(server).post('/api/auth/logout'); expect(res.status).toBe(200); diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index 0c2daf0fe..208d5ed2b 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -1,8 +1,10 @@ /** * OIDC e2e — exercises the migrated /api/auth/oidc flow with the real cookie - * service. The OIDC service + auth toggles are mocked; this proves the flow is - * unauthenticated, the sso-disabled 403, the login redirect, and that /exchange - * sets the httpOnly trek_session cookie from a valid auth code. + * service. The OIDC service is mocked and the auth toggles are stubbed on the + * container's real AuthService instance (OidcService injects it since the auth + * DI fold — the old services/authService path mock would silently miss); this + * proves the flow is unauthenticated, the sso-disabled 403, the login redirect, + * and that /exchange sets the httpOnly trek_session cookie from a valid auth code. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -15,8 +17,26 @@ vi.mock('../../src/app-config', async (importOriginal) => { return { ...actual, getAppUrl: () => 'https://app' }; }); -const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } })); -vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles })); +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + // Bare :memory: db — nothing in these flows queries it (the toggles are + // stubbed on the AuthService instance below), the container just needs a + // connection to construct the auth/permissions/atlas providers. + return { db: new Database(':memory:') }; +}); +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, +})); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); + +const toggles = { oidc_login: true }; const { oidcSvc } = vi.hoisted(() => ({ oidcSvc: { @@ -28,6 +48,8 @@ const { oidcSvc } = vi.hoisted(() => ({ vi.mock('../../src/services/oidcService', () => oidcSvc); import { OidcModule } from '../../src/nest/oidc/oidc.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { AuthService } from '../../src/nest/auth/auth.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('OIDC e2e (real cookie service)', () => { @@ -35,7 +57,7 @@ describe('OIDC e2e (real cookie service)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OidcModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -47,6 +69,7 @@ describe('OIDC e2e (real cookie service)', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); app = await build(); server = app.getHttpServer(); + vi.spyOn(app.get(AuthService), 'resolveAuthToggles').mockImplementation(() => toggles as never); oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }); oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' }); oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 6abfab025..82ec7d8b2 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -5,6 +5,7 @@ import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp'; import { AssignmentsService } from '../../src/nest/assignments/assignments.service'; import { AtlasMcp } from '../../src/nest/atlas/atlas.mcp'; import { AtlasService } from '../../src/nest/atlas/atlas.service'; +import { AuthService } from '../../src/nest/auth/auth.service'; import { BudgetMcp } from '../../src/nest/budget/budget.mcp'; import { BudgetService } from '../../src/nest/budget/budget.service'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; @@ -54,6 +55,7 @@ import { RealtimeService } from '../../src/nest/realtime/realtime.service'; export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); const permissionsService = new PermissionsService(dbService); + const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService)); const realtimeService = new RealtimeService(); const daysService = new DaysService(dbService, permissionsService, realtimeService); const exchangeRatesService = new ExchangeRatesService(); @@ -80,23 +82,23 @@ export function createMcpTestRegistry(): McpRegistry { ); return createTestRegistry( [ - new TagsMcp(new TagsService(dbService)), + new TagsMcp(new TagsService(dbService), authService), new CategoriesMcp(new CategoriesService(dbService)), - new TodoMcp(todoService), - new PackingMcp(packingService), - new BudgetMcp(budgetService, exchangeRatesService, dbService), - new ReservationsMcp(reservationsService, daysService, budgetService), - new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)), - new DaysMcp(daysService, dbService, placesService), - new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService), - new CollabMcp(collabService), - new VacayMcp(new VacayService(dbService, realtimeService)), - new TripsMcp(tripsService, todoService, collabService), - new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)), + new TodoMcp(todoService, authService), + new PackingMcp(packingService, authService), + new BudgetMcp(budgetService, exchangeRatesService, dbService, authService), + new ReservationsMcp(reservationsService, daysService, budgetService, authService), + new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService), + new DaysMcp(daysService, dbService, placesService, authService), + new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService, authService), + new CollabMcp(collabService, authService), + new VacayMcp(new VacayService(dbService, realtimeService), authService), + new TripsMcp(tripsService, todoService, collabService, authService), + new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService), authService), new MapsMcp(mapsService), - new PlacesMcp(placesService, mapsService, dbService), - new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService), - new TransitMcp(new TransitService(), daysService, reservationsService, dbService), + new PlacesMcp(placesService, mapsService, dbService, authService), + new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService), dbService, authService), + new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService), new AtlasMcp(new AtlasService(dbService)), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index d94e15b8b..ce1b56b2c 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -554,7 +554,7 @@ describe('Extended auth scenarios', () => { }); it('AUTH-033 — MFA backup code login succeeds and invalidates the used code', async () => { - const { hashBackupCode, generateBackupCodes } = await import('../../src/services/authService'); + const { hashBackupCode, generateBackupCodes } = await import('../../src/nest/auth/auth.helpers'); const { user, password } = createUserWithMfa(testDb); // Generate and store backup codes on the MFA-enabled user diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 89154628e..5a5e0a322 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -2,8 +2,7 @@ * Unit tests for the DI-native AtlasService and the atlas-geo helper module — * ATLAS-UNIT-001..028 and ATLAS-SVC-001..028 moved 1:1 from the legacy * tests/unit/services/atlasService.test.ts (case IDs preserved), plus - * ATLAS-SVC-029/030 pinning the atlas.bridge.ts delegation for the remaining - * legacy consumer (authService). Uses a real in-memory SQLite DB so the SQL + * Uses a real in-memory SQLite DB so the SQL * logic is exercised faithfully; the pure-geo functions are imported straight * from atlas-geo (their caches are module-scoped there on purpose). */ @@ -43,7 +42,6 @@ import { createUser, createTrip, createReservation } from '../../helpers/factori import { getCached, setCache, getCountryFromCoords, getCountryFromAddress, isPointInCountryBox, reverseGeocodeCountry, getRegionGeo, getCountryGeo } from '../../../src/nest/atlas/atlas-geo'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { AtlasService } from '../../../src/nest/atlas/atlas.service'; -import { getCountryFromCoords as bridgeGetCountryFromCoords, getHiddenCountries as bridgeGetHiddenCountries } from '../../../src/nest/atlas/atlas.bridge'; // Direct construction over the shared test connection — no TestingModule (repo // convention for DI-native service unit tests). @@ -1055,23 +1053,14 @@ describe('unmarkRegionVisited — tombstones + country cascade', () => { }); }); -// ── atlas.bridge.ts delegation (the authService consumer path) ────────────── +// ── atlas.bridge.ts delegation ─────────────────────────────────────────────── +// ATLAS-SVC-029/030 retired with atlas.bridge.ts itself: the sole consumer +// (legacy authService) went DI-native and injects AtlasService now. 030's +// coordinate pin lives on in ATLAS-SVC-030b below. -describe('atlas.bridge delegation', () => { - it('ATLAS-SVC-029: bridge getHiddenCountries reflects tombstones written through the service', () => { - const { user } = createUser(testDb); - atlas.unmarkCountry(user.id, 'JP'); - - // The bridge's own AtlasService instance runs over the db Proxy (mocked to - // this suite's testDb), so it must see the same hidden_countries row. - const hidden = bridgeGetHiddenCountries(user.id); - expect(hidden.has('JP')).toBe(true); - expect(hidden.has('FR')).toBe(false); - }); - - it('ATLAS-SVC-030: bridge getCountryFromCoords is the atlas-geo function (shared indexes)', () => { - expect(bridgeGetCountryFromCoords).toBe(getCountryFromCoords); - expect(bridgeGetCountryFromCoords(48.85, 2.35)).toBe('FR'); +describe('getCountryFromCoords coordinate pin', () => { + it('ATLAS-SVC-030b: resolves Paris coordinates to FR (shared atlas-geo indexes)', () => { + expect(getCountryFromCoords(48.85, 2.35)).toBe('FR'); }); }); diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index 8bb25c8da..b5c915548 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -3,7 +3,6 @@ import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); -vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() })); vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() })); vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); @@ -32,7 +31,7 @@ const audit = { writeAudit } as unknown as AuditService; const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit); import { CurrentUser } from '../../../src/nest/auth/current-user.decorator'; import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth'; -import { resolveAuthToggles } from '../../../src/services/authService'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; import { setAuthCookie } from '../../../src/services/cookie'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import * as passkey from '../../../src/services/passkeyService'; @@ -164,15 +163,18 @@ describe('AdminGuard', () => { }); describe('PasskeyEnabledGuard', () => { - const guard = new PasskeyEnabledGuard(); + // The guard injects AuthService since the auth DI fold; a resolveAuthToggles + // stub replaces the old services/authService path mock. + const resolveAuthToggles = vi.fn(); + const guard = new PasskeyEnabledGuard({ resolveAuthToggles } as unknown as AuthService); it('404s when passkey_login is off', () => { - vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: false } as ReturnType); + resolveAuthToggles.mockReturnValue({ passkey_login: false }); expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } }); }); it('allows when passkey_login is on', () => { - vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: true } as ReturnType); + resolveAuthToggles.mockReturnValue({ passkey_login: true }); expect(guard.canActivate()).toBe(true); }); }); diff --git a/server/tests/unit/services/authService.test.ts b/server/tests/unit/nest/auth.helpers.test.ts similarity index 93% rename from server/tests/unit/services/authService.test.ts rename to server/tests/unit/nest/auth.helpers.test.ts index ec456d3c5..6f9aeecbc 100644 --- a/server/tests/unit/services/authService.test.ts +++ b/server/tests/unit/nest/auth.helpers.test.ts @@ -1,31 +1,31 @@ +/** + * Pure-function half of the auth unit suite, moved 1:1 from the legacy + * tests/unit/services/authService.test.ts when the auth backend went + * DI-native (case IDs preserved — SEC-008 secret-omission block). The DB + * half lives in tests/unit/nest/auth.service.test.ts. Only the apiKeyCrypto + * mock survives (auth.helpers imports it for mask_stored_api_key); the + * legacy db/permissions/mcp/scheduler mocks guarded imports the pure module + * no longer has. + */ import { describe, it, expect, vi } from 'vitest'; -vi.mock('../../../src/db/database', () => ({ - db: { prepare: () => ({ get: vi.fn(), all: vi.fn(), run: vi.fn() }) }, - canAccessTrip: vi.fn(), -})); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -vi.mock('../../../src/services/mfaCrypto', () => ({ encryptMfaSecret: vi.fn(), decryptMfaSecret: vi.fn() })); vi.mock('../../../src/services/apiKeyCrypto', () => ({ decrypt_api_key: vi.fn((v) => v), maybe_encrypt_api_key: vi.fn((v) => v), encrypt_api_key: vi.fn((v) => v), })); -vi.mock('../../../src/nest/permissions/permissions.bridge', () => ({ getAllPermissions: vi.fn(() => ({})), checkPermission: vi.fn() })); -vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() })); -vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() })); -vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn() })); import { utcSuffix, stripUserForClient, maskKey, - avatarUrl, normalizeBackupCode, hashBackupCode, generateBackupCodes, parseBackupCodeHashes, -} from '../../../src/services/authService'; +} from '../../../src/nest/auth/auth.helpers'; +import { avatarUrl } from '../../../src/services/avatarUrl'; import type { User } from '../../../src/types'; // ── utcSuffix ──────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/authServiceDb.test.ts b/server/tests/unit/nest/auth.service.test.ts similarity index 50% rename from server/tests/unit/services/authServiceDb.test.ts rename to server/tests/unit/nest/auth.service.test.ts index ae5ff5f99..224dfcf93 100644 --- a/server/tests/unit/services/authServiceDb.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -1,9 +1,12 @@ /** - * authServiceDb.test.ts + * auth.service.test.ts * - * DB-centric unit tests for authService.ts using a real in-memory SQLite database. - * Pure function tests live in authService.test.ts (stub DB); this file covers - * functions that require actual DB queries to exercise their logic. + * DB-centric unit tests for the DI-native AuthService using a real in-memory + * SQLite database, moved 1:1 from the legacy tests/unit/services/ + * authServiceDb.test.ts (AUTH-DB-* case IDs preserved). Pure function tests + * live in auth.helpers.test.ts. Constructed directly (no TestingModule, repo + * convention); the trailing describe covers the auth.bridge.ts delegation + * exports for the src/nest coverage gate. */ // --------------------------------------------------------------------------- @@ -37,6 +40,7 @@ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, updateJwtSecret: () => {}, })); vi.mock('../../../src/services/mfaCrypto', () => ({ @@ -49,12 +53,9 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({ mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)), encrypt_api_key: vi.fn((v) => v), })); -vi.mock('../../../src/nest/permissions/permissions.bridge', () => ({ - getAllPermissions: vi.fn(() => ({})), - checkPermission: vi.fn(), -})); vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() })); -vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() })); +vi.mock('../../../src/services/notifications', () => ({ sendPasswordResetEmail: vi.fn() })); +vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn(), @@ -71,32 +72,21 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, createInviteToken, createTrip, createReservation } from '../../helpers/factories'; -import { - updateSettings, - updateApiKeys, - getSettings, - listUsers, - getAppSettings, - validateKeys, - isOidcOnlyMode, - resolveAuthToggles, - setupMfa, - enableMfa, - disableMfa, - validateInviteToken, - registerUser, - loginUser, - requestPasswordReset, - changePassword, - verifyMfaLogin, - createMcpToken, - deleteMcpToken, - generateToken, - getTravelStats, -} from '../../../src/services/authService'; +import { AuthService } from '../../../src/nest/auth/auth.service'; +import * as authBridge from '../../../src/nest/auth/auth.bridge'; import { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { verifyJwtAndLoadUser } from '../../../src/middleware/auth'; +import { authenticator } from 'otplib'; +import { hashBackupCode } from '../../../src/nest/auth/auth.helpers'; +import { createEphemeralToken } from '../../../src/services/ephemeralTokens'; + +const svc = new AuthService( + new DatabaseService(testDb), + new PermissionsService(new DatabaseService(testDb)), + new AtlasService(new DatabaseService(testDb)), +); // --------------------------------------------------------------------------- // Lifecycle @@ -123,7 +113,7 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') .run('sub-1129', 'https://idp.example', user.id); - const result = requestPasswordReset(user.email, null); + const result = svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('oidc_only'); expect(result.tokenForDelivery).toBeNull(); @@ -134,7 +124,7 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => { const { user } = createUser(testDb); - const result = requestPasswordReset(user.email, null); + const result = svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('issued'); expect(result.tokenForDelivery).toBeTruthy(); }); @@ -147,21 +137,21 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { describe('updateSettings', () => { it('AUTH-DB-001: updates username successfully', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, { username: 'newname' }); + const result = svc.updateSettings(user.id, { username: 'newname' }); expect(result.success).toBe(true); expect(result.user?.username).toBe('newname'); }); it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, { username: 'x' }); + const result = svc.updateSettings(user.id, { username: 'x' }); expect(result.status).toBe(400); expect(result.error).toMatch(/between 2 and 50/i); }); it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, { username: 'bad name' }); + const result = svc.updateSettings(user.id, { username: 'bad name' }); expect(result.status).toBe(400); expect(result.error).toMatch(/only contain/i); }); @@ -169,21 +159,21 @@ describe('updateSettings', () => { it('AUTH-DB-004: returns 409 when username is already taken by another user', () => { const { user: user1 } = createUser(testDb, { username: 'alice' }); const { user: user2 } = createUser(testDb, { username: 'bob' }); - const result = updateSettings(user2.id, { username: user1.username }); + const result = svc.updateSettings(user2.id, { username: user1.username }); expect(result.status).toBe(409); expect(result.error).toMatch(/already taken/i); }); it('AUTH-DB-005: updates email successfully', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, { email: 'new@example.com' }); + const result = svc.updateSettings(user.id, { email: 'new@example.com' }); expect(result.success).toBe(true); expect(result.user?.email).toBe('new@example.com'); }); it('AUTH-DB-006: returns 400 for invalid email format', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, { email: 'not-an-email' }); + const result = svc.updateSettings(user.id, { email: 'not-an-email' }); expect(result.status).toBe(400); expect(result.error).toMatch(/invalid email/i); }); @@ -191,14 +181,14 @@ describe('updateSettings', () => { it('AUTH-DB-007: returns 409 when email is already taken by another user', () => { const { user: user1 } = createUser(testDb, { email: 'taken@example.com' }); const { user: user2 } = createUser(testDb); - const result = updateSettings(user2.id, { email: user1.email }); + const result = svc.updateSettings(user2.id, { email: user1.email }); expect(result.status).toBe(409); expect(result.error).toMatch(/already taken/i); }); it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => { const { user } = createUser(testDb); - const result = updateSettings(user.id, {}); + const result = svc.updateSettings(user.id, {}); expect(result.success).toBe(true); }); }); @@ -210,7 +200,7 @@ describe('updateSettings', () => { describe('getSettings', () => { it('AUTH-DB-009: returns 403 for non-admin user', () => { const { user } = createUser(testDb); - const result = getSettings(user.id); + const result = svc.getSettings(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); }); @@ -220,7 +210,7 @@ describe('getSettings', () => { testDb .prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?') .run('maps-key-value', 'weather-key-value', user.id); - const result = getSettings(user.id); + const result = svc.getSettings(user.id); expect(result.status).toBeUndefined(); expect(result.settings).toBeDefined(); expect(result.settings).toHaveProperty('maps_api_key'); @@ -229,11 +219,11 @@ describe('getSettings', () => { it('AUTH-DB-010b: round-trips unsplash_api_key through updateApiKeys — masked to the client, readable via getSettings', () => { const { user } = createAdmin(testDb); - const result = updateApiKeys(user.id, { unsplash_api_key: 'unsplash-secret-key' }); + const result = svc.updateApiKeys(user.id, { unsplash_api_key: 'unsplash-secret-key' }); // Returned to the client masked, never in plaintext. expect(result.user.unsplash_api_key).toBe('-----key'); // getSettings returns the stored key to the admin. - expect(getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key'); + expect(svc.getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key'); }); }); @@ -247,7 +237,7 @@ describe('listUsers', () => { createUser(testDb, { username: 'alice' }); createUser(testDb, { username: 'charlie' }); createUser(testDb, { username: 'bob' }); - const result = listUsers(self.id); + const result = svc.listUsers(self.id); expect(result).toHaveLength(3); const names = result.map((u) => u.username); expect(names).toEqual([...names].sort()); @@ -256,7 +246,7 @@ describe('listUsers', () => { it('AUTH-DB-012: returns empty array when only one user exists', () => { const { user } = createUser(testDb); - const result = listUsers(user.id); + const result = svc.listUsers(user.id); expect(result).toHaveLength(0); }); }); @@ -268,7 +258,7 @@ describe('listUsers', () => { describe('getAppSettings', () => { it('AUTH-DB-013: returns 403 for non-admin', () => { const { user } = createUser(testDb); - const result = getAppSettings(user.id); + const result = svc.getAppSettings(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); }); @@ -278,7 +268,7 @@ describe('getAppSettings', () => { testDb .prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')") .run(); - const result = getAppSettings(user.id); + const result = svc.getAppSettings(user.id); expect(result.status).toBeUndefined(); expect(result.data).toBeDefined(); expect(result.data).toHaveProperty('allow_registration', 'true'); @@ -292,7 +282,7 @@ describe('getAppSettings', () => { describe('validateKeys', () => { it('AUTH-DB-015: returns 403 for non-admin', async () => { const { user } = createUser(testDb); - const result = await validateKeys(user.id); + const result = await svc.validateKeys(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); expect(result.maps).toBe(false); @@ -301,7 +291,7 @@ describe('validateKeys', () => { it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => { const { user } = createAdmin(testDb); - const result = await validateKeys(user.id); + const result = await svc.validateKeys(user.id); expect(result.maps).toBe(false); expect(result.weather).toBe(false); expect(result.maps_details).toBeNull(); @@ -317,7 +307,7 @@ describe('validateKeys', () => { text: async () => '', } as Response); - const result = await validateKeys(user.id); + const result = await svc.validateKeys(user.id); expect(result.maps).toBe(true); expect(result.maps_details?.ok).toBe(true); @@ -332,7 +322,7 @@ describe('validateKeys', () => { .spyOn(global, 'fetch') .mockRejectedValueOnce(new Error('Network failure')); - const result = await validateKeys(user.id); + const result = await svc.validateKeys(user.id); expect(result.maps).toBe(false); expect(result.maps_details?.error_status).toBe('FETCH_ERROR'); expect(result.maps_details?.error_message).toBe('Network failure'); @@ -348,7 +338,7 @@ describe('validateKeys', () => { describe('isOidcOnlyMode', () => { it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => { vi.stubEnv('OIDC_ONLY', ''); - expect(isOidcOnlyMode()).toBe(false); + expect(svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); @@ -356,7 +346,7 @@ describe('isOidcOnlyMode', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', ''); vi.stubEnv('OIDC_CLIENT_ID', ''); - expect(isOidcOnlyMode()).toBe(false); + expect(svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); @@ -364,7 +354,7 @@ describe('isOidcOnlyMode', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); - expect(isOidcOnlyMode()).toBe(true); + expect(svc.isOidcOnlyMode()).toBe(true); vi.unstubAllEnvs(); }); }); @@ -381,7 +371,7 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => { vi.stubEnv('OIDC_ONLY', ''); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(true); expect(t.oidc_login).toBe(true); @@ -392,7 +382,7 @@ describe('resolveAuthToggles', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); @@ -402,7 +392,7 @@ describe('resolveAuthToggles', () => { it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); @@ -413,7 +403,7 @@ describe('resolveAuthToggles', () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); // New key present → use new keys, allow_registration ignored expect(t.password_registration).toBe(true); expect(t.oidc_registration).toBe(true); // defaults to true when key not set @@ -423,7 +413,7 @@ describe('resolveAuthToggles', () => { vi.stubEnv('OIDC_ONLY', 'true'); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); // OIDC_ONLY forces password toggles off even when DB says true expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); @@ -435,7 +425,7 @@ describe('resolveAuthToggles', () => { testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run(); - const t = resolveAuthToggles(); + const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); @@ -451,7 +441,7 @@ describe('setupMfa', () => { it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); - const result = setupMfa(user.id, 'demo@nomad.app'); + const result = svc.setupMfa(user.id, 'demo@nomad.app'); expect(result.status).toBe(403); expect(result.error).toMatch(/demo mode/i); vi.unstubAllEnvs(); @@ -460,14 +450,14 @@ describe('setupMfa', () => { it('AUTH-DB-023: returns 400 when MFA is already enabled', () => { const { user } = createUser(testDb); testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); - const result = setupMfa(user.id, user.email); + const result = svc.setupMfa(user.id, user.email); expect(result.status).toBe(400); expect(result.error).toMatch(/already enabled/i); }); it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => { const { user } = createUser(testDb); - const result = setupMfa(user.id, user.email); + const result = svc.setupMfa(user.id, user.email); expect(result.error).toBeUndefined(); expect(typeof result.secret).toBe('string'); expect(result.secret!.length).toBeGreaterThan(0); @@ -484,7 +474,7 @@ describe('setupMfa', () => { describe('enableMfa', () => { it('AUTH-DB-025: returns 400 when no verification code is provided', () => { const { user } = createUser(testDb); - const result = enableMfa(user.id, undefined); + const result = svc.enableMfa(user.id, undefined); expect(result.status).toBe(400); expect(result.error).toMatch(/code is required/i); }); @@ -492,7 +482,7 @@ describe('enableMfa', () => { it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => { const { user } = createUser(testDb); // No setupMfa called first, so no pending entry exists - const result = enableMfa(user.id, '123456'); + const result = svc.enableMfa(user.id, '123456'); expect(result.status).toBe(400); expect(result.error).toMatch(/no mfa setup in progress/i); }); @@ -506,7 +496,7 @@ describe('disableMfa', () => { it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); - const result = disableMfa(user.id, 'demo@nomad.app', { + const result = svc.disableMfa(user.id, 'demo@nomad.app', { password: 'password123', code: '000000', }); @@ -518,11 +508,11 @@ describe('disableMfa', () => { it('AUTH-DB-028: returns 400 when password or code is missing', () => { const { user } = createUser(testDb); - const missingCode = disableMfa(user.id, user.email, { password: 'pass', code: undefined }); + const missingCode = svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined }); expect(missingCode.status).toBe(400); expect(missingCode.error).toMatch(/password and authenticator code/i); - const missingPassword = disableMfa(user.id, user.email, { password: undefined, code: '123456' }); + const missingPassword = svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' }); expect(missingPassword.status).toBe(400); expect(missingPassword.error).toMatch(/password and authenticator code/i); }); @@ -530,7 +520,7 @@ describe('disableMfa', () => { it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => { const { user } = createUser(testDb); // mfa_enabled defaults to 0 / not set - const result = disableMfa(user.id, user.email, { password: 'password123', code: '000000' }); + const result = svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' }); expect(result.status).toBe(400); expect(result.error).toMatch(/not enabled/i); }); @@ -542,7 +532,7 @@ describe('disableMfa', () => { describe('validateInviteToken', () => { it('AUTH-DB-030: returns 404 for unknown token', () => { - const result = validateInviteToken('no-such-token'); + const result = svc.validateInviteToken('no-such-token'); expect(result.status).toBe(404); }); @@ -551,13 +541,13 @@ describe('validateInviteToken', () => { const invite = createInviteToken(testDb, { max_uses: 1 }); // manually set used_count = 1 to simulate exhaustion testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); - const result = validateInviteToken(invite.token); + const result = svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); it('AUTH-DB-032: returns 410 when expired', () => { const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' }); - const result = validateInviteToken(invite.token); + const result = svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); }); @@ -573,7 +563,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => { testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); + const result = svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); expect(result.error).toMatch(/password registration is disabled/i); }); @@ -582,7 +572,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => { createUser(testDb); // ensure userCount > 0 testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - const result = registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); + const result = svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); }); }); @@ -598,7 +588,7 @@ describe('loginUser — OIDC-only mode', () => { testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = loginUser({ email: user.email, password }); + const result = svc.loginUser({ email: user.email, password }); expect(result.status).toBe(403); }); }); @@ -614,7 +604,7 @@ describe('changePassword — OIDC-only mode', () => { testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.status).toBe(403); }); }); @@ -627,12 +617,12 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => { const { user, password } = createUser(testDb); - createMcpToken(user.id, 'cli'); + svc.createMcpToken(user.id, 'cli'); expect(pvOf(user.id)).toBe(0); expect(mcpCount(user.id)).toBe(1); - const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.success).toBe(true); expect(typeof result.token).toBe('string'); // fresh session for the current device @@ -642,11 +632,11 @@ describe('changePassword — session invalidation', () => { it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => { const { user, password } = createUser(testDb); - const stolen = generateToken({ id: user.id }); // pv=0 at mint time + const stolen = svc.generateToken({ id: user.id }); // pv=0 at mint time expect(verifyJwtAndLoadUser(stolen)).not.toBeNull(); - changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump }); @@ -661,7 +651,7 @@ describe('disableMfa — require_mfa policy', () => { const { user } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); - const result = disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); + const result = svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); expect(result.status).toBe(403); expect(result.error).toMatch(/cannot be disabled/i); }); @@ -673,7 +663,7 @@ describe('disableMfa — require_mfa policy', () => { describe('verifyMfaLogin — validation', () => { it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => { - const result = verifyMfaLogin({ mfa_token: undefined, code: undefined }); + const result = svc.verifyMfaLogin({ mfa_token: undefined, code: undefined }); expect(result.status).toBe(400); }); @@ -681,7 +671,7 @@ describe('verifyMfaLogin — validation', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); - const result = verifyMfaLogin({ mfa_token: tok, code: '123456' }); + const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); expect(result.error).toMatch(/invalid/i); }); @@ -690,7 +680,7 @@ describe('verifyMfaLogin — validation', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); - const result = verifyMfaLogin({ mfa_token: tok, code: '123456' }); + const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); }); }); @@ -702,19 +692,19 @@ describe('verifyMfaLogin — validation', () => { describe('MCP token service', () => { it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => { const { user } = createUser(testDb); - const result = createMcpToken(user.id, undefined); + const result = svc.createMcpToken(user.id, undefined); expect(result.status).toBe(400); }); it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => { const { user } = createUser(testDb); - const result = createMcpToken(user.id, 'a'.repeat(101)); + const result = svc.createMcpToken(user.id, 'a'.repeat(101)); expect(result.status).toBe(400); }); it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => { const { user } = createUser(testDb); - const result = createMcpToken(user.id, 'My Token'); + const result = svc.createMcpToken(user.id, 'My Token'); expect(result.token).toBeDefined(); expect((result.token as any).raw_token).toMatch(/^trek_/); }); @@ -726,22 +716,22 @@ describe('MCP token service', () => { 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' ).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`); } - const result = createMcpToken(user.id, 'One More'); + const result = svc.createMcpToken(user.id, 'One More'); expect(result.status).toBe(400); }); it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => { const { user } = createUser(testDb); - const result = deleteMcpToken(user.id, '99999'); + const result = svc.deleteMcpToken(user.id, '99999'); expect(result.status).toBe(404); }); it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => { const { user } = createUser(testDb); - const created = createMcpToken(user.id, 'Deletable Token'); + const created = svc.createMcpToken(user.id, 'Deletable Token'); const tokenId = String((created.token as any).id); - const result = deleteMcpToken(user.id, tokenId); + const result = svc.deleteMcpToken(user.id, tokenId); expect(result).toEqual({ success: true }); const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId); @@ -765,7 +755,7 @@ describe('getTravelStats', () => { endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo - const stats = getTravelStats(user.id); + const stats = svc.getTravelStats(user.id); expect(stats.countries).toContain('BE'); expect(stats.countries).toContain('JP'); }); @@ -780,7 +770,7 @@ describe('getTravelStats', () => { endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney - const stats = getTravelStats(user.id); + const stats = svc.getTravelStats(user.id); expect(stats.countries).toContain('BE'); expect(stats.countries).toContain('AU'); expect(stats.countries).not.toContain('JP'); @@ -793,12 +783,459 @@ describe('getTravelStats', () => { endpoint(res.id, 'from', 0, 50.9014, 4.4844); endpoint(res.id, 'to', 1, 35.6762, 139.6503); - expect(getTravelStats(user.id).countries).toContain('JP'); + expect(svc.getTravelStats(user.id).countries).toContain('JP'); new AtlasService(new DatabaseService(testDb)).unmarkCountry(user.id, 'JP'); - const after = getTravelStats(user.id); + const after = svc.getTravelStats(user.id); expect(after.countries).not.toContain('JP'); expect(after.countries).toContain('BE'); }); }); + + +// --------------------------------------------------------------------------- +// Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines +// under the src/nest coverage gate, so the previously untested branches — +// app-config fan-out, login paths, MFA success flows, reset consumption, +// admin settings — get pinned here. +// --------------------------------------------------------------------------- + +describe('getAppConfig', () => { + it('AUTH-DB-050: anonymous caller gets toggles/version and no permissions block', () => { + vi.stubEnv('OIDC_ONLY', ''); + createUser(testDb); + const cfg = svc.getAppConfig(null); + expect(cfg.password_login).toBe(true); + expect(cfg.has_users).toBe(true); + expect(typeof cfg.version).toBe('string'); + expect(cfg.permissions).toBeUndefined(); + expect(cfg.notification_channel).toBe('none'); + expect(cfg.available_channels.inapp).toBe(true); + expect(cfg.allowed_file_types).toContain('jpg'); + vi.unstubAllEnvs(); + }); + + it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', () => { + const { user } = createUser(testDb); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('notification_channels', 'email,webhook')").run(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'jpg,png')").run(); + const cfg = svc.getAppConfig({ id: user.id } as never); + expect(cfg.permissions).toBeDefined(); + expect(cfg.require_mfa).toBe(true); + expect(cfg.notification_channels).toEqual(['email', 'webhook']); + expect(cfg.available_channels.webhook).toBe(true); + expect(cfg.allowed_file_types).toBe('jpg,png'); + }); + + it('AUTH-DB-052: demo mode forces registration toggles off and surfaces the demo credentials', () => { + vi.stubEnv('DEMO_MODE', 'true'); + createUser(testDb); + const cfg = svc.getAppConfig(null); + expect(cfg.demo_mode).toBe(true); + expect(cfg.password_registration).toBe(false); + expect(cfg.oidc_registration).toBe(false); + expect(cfg.demo_email).toBeDefined(); + expect(cfg.demo_password).toBe('demo12345'); + vi.unstubAllEnvs(); + }); +}); + +describe('demoLogin', () => { + it('AUTH-DB-053: 404 outside demo mode', () => { + vi.stubEnv('DEMO_MODE', ''); + expect(svc.demoLogin()).toEqual({ error: 'Not found', status: 404 }); + vi.unstubAllEnvs(); + }); + + it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', () => { + vi.stubEnv('DEMO_MODE', 'true'); + expect(svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 }); + // demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias). + createUser(testDb, { email: 'demo@trek.app' }); + const result = svc.demoLogin(); + expect(typeof result.token).toBe('string'); + expect(result.user).not.toHaveProperty('password_hash'); + vi.unstubAllEnvs(); + }); +}); + +describe('validateInviteToken — valid path', () => { + it('AUTH-DB-055: returns valid with usage metadata', () => { + const invite = createInviteToken(testDb, { max_uses: 5 }); + const result = svc.validateInviteToken(invite.token); + expect(result.valid).toBe(true); + expect(result.max_uses).toBe(5); + expect(result.used_count).toBe(0); + }); +}); + +describe('registerUser — success paths', () => { + it('AUTH-DB-056: first user becomes admin and gets a token', () => { + const result = svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' }); + expect(result.error).toBeUndefined(); + expect(typeof result.token).toBe('string'); + expect((result.user as { role: string }).role).toBe('admin'); + expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' }); + }); + + it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', () => { + createUser(testDb, { username: 'taken', email: 'taken@x.com' }); + expect(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' })) + .toEqual({ error: 'Username, email and password are required', status: 400 }); + expect(svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' })) + .toEqual({ error: 'Invalid email format', status: 400 }); + expect(svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' })) + .toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 }); + }); + + it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', () => { + createUser(testDb); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + const invite = createInviteToken(testDb, { max_uses: 2 }); + const result = svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }); + expect(result.error).toBeUndefined(); + const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; + expect(used_count).toBe(1); + testDb.prepare("DELETE FROM app_settings WHERE key = 'allow_registration'").run(); + }); +}); + +describe('loginUser — credential branches', () => { + it('AUTH-DB-059: unknown email answers the generic 401', () => { + const result = svc.loginUser({ email: 'nobody@x.com', password: 'pw' }); + expect(result).toMatchObject({ error: 'Invalid email or password', status: 401, auditUserId: null }); + expect(result.auditDetails).toMatchObject({ reason: 'unknown_email' }); + }); + + it('AUTH-DB-060: missing fields answer the bespoke 400', () => { + expect(svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 }); + }); + + it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', () => { + const { user } = createUser(testDb); + const result = svc.loginUser({ email: user.email, password: 'nope' }); + expect(result.status).toBe(401); + expect(result.auditDetails).toMatchObject({ reason: 'wrong_password' }); + }); + + it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', () => { + const { user, password } = createUser(testDb); + const result = svc.loginUser({ email: user.email, password }); + expect(typeof result.token).toBe('string'); + expect(result.user).not.toHaveProperty('password_hash'); + expect(result.auditAction).toBe('user.login'); + const row = testDb.prepare('SELECT login_count, last_login FROM users WHERE id = ?').get(user.id) as { login_count: number; last_login: string }; + expect(row.login_count).toBe(1); + expect(row.last_login).not.toBeNull(); + }); + + it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id); + const { password } = { password: undefined }; + void password; + const result = svc.loginUser({ email: user.email, password: 'TestPass' }); + // wrong password still 401s before the MFA branch + expect(result.status).toBe(401); + }); +}); + +describe('getCurrentUser', () => { + it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', () => { + const { user } = createUser(testDb); + const loaded = svc.getCurrentUser(user.id); + expect(loaded?.id).toBe(user.id); + expect(loaded).not.toHaveProperty('password_hash'); + expect(svc.getCurrentUser(999999)).toBeNull(); + }); +}); + +describe('deleteAccount', () => { + it('AUTH-DB-065: refuses to delete the last admin', () => { + const { user } = createAdmin(testDb); + expect(svc.deleteAccount(user.id, user.email, 'admin')) + .toEqual({ error: 'Cannot delete the last admin account', status: 400 }); + }); + + it('AUTH-DB-066: demo mode blocks deletion', () => { + vi.stubEnv('DEMO_MODE', 'true'); + expect(svc.deleteAccount(1, 'demo@nomad.app', 'user')) + .toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 }); + vi.unstubAllEnvs(); + }); + + it('AUTH-DB-067: deletes a regular user row', () => { + const { user } = createUser(testDb); + expect(svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true }); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined(); + }); +}); + +describe('updateMapsKey / avatar', () => { + it('AUTH-DB-068: updateMapsKey stores and answers the masked key', () => { + const { user } = createUser(testDb); + const result = svc.updateMapsKey(user.id, 'maps-key-123456'); + expect(result.success).toBe(true); + expect(result.maps_api_key).toBe('----3456'); + }); + + it('AUTH-DB-069: saveAvatar updates the row and answers the public url', async () => { + const { user } = createUser(testDb); + const result = await svc.saveAvatar(user.id, 'new.png'); + expect(result).toEqual({ success: true, avatar_url: '/uploads/avatars/new.png' }); + }); + + it('AUTH-DB-070: deleteAvatar nulls the column; an OIDC https avatar skips the file rm', async () => { + const { user } = createUser(testDb); + testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp/pic.jpg', user.id); + expect(await svc.deleteAvatar(user.id)).toEqual({ success: true }); + const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as { avatar: string | null }; + expect(row.avatar).toBeNull(); + }); +}); + +describe('updateAppSettings', () => { + it('AUTH-DB-071: 403 for non-admin', () => { + const { user } = createUser(testDb); + expect(svc.updateAppSettings(user.id, {}).status).toBe(403); + }); + + it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', () => { + const { user } = createAdmin(testDb); + const result = svc.updateAppSettings(user.id, { require_mfa: true }); + expect(result.status).toBe(400); + expect(result.error).toMatch(/secure your own account/i); + }); + + it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', () => { + const { user } = createAdmin(testDb); + testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); + const result = svc.updateAppSettings(user.id, { require_mfa: true }); + expect(result.success).toBe(true); + expect(result.auditSummary).toMatchObject({ require_mfa: true }); + testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run(); + }); + + it('AUTH-DB-074: lockout prevention refuses disabling every login method', () => { + const { user } = createAdmin(testDb); + const result = svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' }); + expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 }); + }); + + it('AUTH-DB-075: the smtp_pass masking sentinel is skipped, notification changes flag a scheduler restart', () => { + const { user } = createAdmin(testDb); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run(); + const result = svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }); + expect(result.success).toBe(true); + expect(result.shouldRestartScheduler).toBe(true); + const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string }; + expect(value).toBe('stored'); // sentinel never overwrites the secret + expect(result.auditDebugDetails).not.toHaveProperty('smtp_pass', 'stored'); + testDb.prepare("DELETE FROM app_settings WHERE key IN ('smtp_pass','notification_channels')").run(); + }); +}); + +describe('MFA success flows', () => { + it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', () => { + const { user } = createUser(testDb); + const setup = svc.setupMfa(user.id, user.email); + const code = authenticator.generate(setup.secret!); + const result = svc.enableMfa(user.id, code); + expect(result.success).toBe(true); + expect(result.backup_codes).toHaveLength(10); + const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string }; + expect(row.mfa_enabled).toBe(1); + expect(row.mfa_secret).toBe('enc:' + setup.secret); + }); + + it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', () => { + const { user } = createUser(testDb); + svc.setupMfa(user.id, user.email); + expect(svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 }); + }); + + it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', () => { + const { user, password } = createUser(testDb); + const secret = authenticator.generateSecret(); + testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + const result = svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }); + expect(result).toEqual({ success: true, mfa_enabled: false }); + const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null }; + expect(row.mfa_enabled).toBe(0); + expect(row.mfa_secret).toBeNull(); + }); + + it('AUTH-DB-079: disableMfa answers 401 on a wrong password', () => { + const { user } = createUser(testDb); + const secret = authenticator.generateSecret(); + testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + expect(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })) + .toEqual({ error: 'Incorrect password', status: 401 }); + }); + + it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', () => { + const { user, password } = createUser(testDb); + const secret = authenticator.generateSecret(); + testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + const interstitial = svc.loginUser({ email: user.email, password }); + expect(interstitial.mfa_required).toBe(true); + const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }); + expect(typeof result.token).toBe('string'); + expect(result.auditUserId).toBe(user.id); + }); + + it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', () => { + const { user, password } = createUser(testDb); + const secret = authenticator.generateSecret(); + const codes = ['AAAA-1111', 'BBBB-2222']; + // hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode. + testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') + .run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), user.id); + const interstitial = svc.loginUser({ email: user.email, password }); + const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }); + expect(typeof result.token).toBe('string'); + const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; + expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out + // the spent code no longer verifies + const again = svc.loginUser({ email: user.email, password }); + expect(svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' }).status).toBe(401); + }); +}); + +describe('resetPassword', () => { + it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', () => { + const { user } = createUser(testDb); + const issued = svc.requestPasswordReset(user.email, '1.2.3.4'); + expect(issued.reason).toBe('issued'); + const result = svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }); + expect(result).toEqual({ success: true, userId: user.id }); + const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; + expect(row.password_version).toBe(1); + // token is burned — a second use answers the bespoke 400 + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })) + .toEqual({ error: 'This reset link has already been used', status: 400 }); + }); + + it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => { + expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 }); + expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 }); + expect(svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' })) + .toEqual({ error: 'Invalid or expired reset link', status: 400 }); + + const { user } = createUser(testDb); + const issued = svc.requestPasswordReset(user.email, null); + testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id); + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + .toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 }); + }); + + it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', () => { + const { user } = createUser(testDb); + const secret = authenticator.generateSecret(); + testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') + .run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id); + const issued = svc.requestPasswordReset(user.email, null); + // no code → mfa_required interstitial, token NOT burned + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + .toEqual({ mfa_required: true, status: 200 }); + // wrong code → 401 + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' })) + .toEqual({ error: 'Invalid MFA code', status: 401 }); + // backup code → success + code consumed + expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' })) + .toEqual({ success: true, userId: user.id }); + const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; + expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); + }); + + it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', () => { + vi.stubEnv('OIDC_ONLY', 'true'); + vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); + vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); + expect(svc.requestPasswordReset('whoever@x.com', null).reason).toBe('password_login_disabled'); + vi.unstubAllEnvs(); + + const { user } = createUser(testDb); + svc.requestPasswordReset(user.email, null); + svc.requestPasswordReset(user.email, null); + svc.requestPasswordReset(user.email, null); + expect(svc.requestPasswordReset(user.email, null).reason).toBe('throttled_per_email'); + }); +}); + +describe('ephemeral + demo helpers', () => { + it('AUTH-DB-086: createResourceToken rejects a non-download purpose and 503s when the store is down', () => { + const { user } = createUser(testDb); + expect(svc.createResourceToken(user.id, 'exfiltrate')).toEqual({ error: 'Invalid purpose', status: 400 }); + expect(svc.createResourceToken(user.id, 'download')).toEqual({ error: 'Service unavailable', status: 503 }); + vi.mocked(createEphemeralToken).mockReturnValueOnce('tok-1'); + expect(svc.createResourceToken(user.id, 'download')).toEqual({ token: 'tok-1' }); + }); + + it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', () => { + const { user } = createUser(testDb); + vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); + expect(svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' }); + }); + + it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', () => { + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const { user: other } = createUser(testDb); + expect(svc.isDemoUser(user.id)).toBe(false); // demo mode off + vi.stubEnv('DEMO_MODE', 'true'); + expect(svc.isDemoUser(user.id)).toBe(true); + expect(svc.isDemoUser(other.id)).toBe(false); + vi.unstubAllEnvs(); + }); +}); + +// --------------------------------------------------------------------------- +// auth.bridge.ts delegation (coverage gate: one case per bridge export) +// --------------------------------------------------------------------------- + +describe('auth.bridge delegation', () => { + it('AUTH-BR-001: isDemoUser returns false outside demo mode', () => { + const { user } = createUser(testDb); + expect(authBridge.isDemoUser(user.id)).toBe(false); + }); + + it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => { + const { user } = createUser(testDb); + const created = svc.createMcpToken(user.id, 'bridge-case'); + const raw = (created.token as { raw_token: string }).raw_token; + const resolved = authBridge.verifyMcpToken(raw); + expect(resolved?.id).toBe(user.id); + expect(authBridge.verifyMcpToken('trek_no_such_token')).toBeNull(); + }); + + it('AUTH-BR-003: generateToken + verifyJwtToken round-trip through the pv gate', () => { + const { user } = createUser(testDb); + const token = authBridge.generateToken({ id: user.id }); + expect(authBridge.verifyJwtToken(token)?.id).toBe(user.id); + expect(authBridge.verifyJwtToken('not-a-jwt')).toBeNull(); + }); + + it('AUTH-BR-004: resolveAuthToggles returns the container defaults', () => { + vi.stubEnv('OIDC_ONLY', ''); + expect(authBridge.resolveAuthToggles().password_login).toBe(true); + vi.unstubAllEnvs(); + }); + + it('AUTH-BR-005: createWsToken surfaces the 503 when the ephemeral store is down', () => { + const { user } = createUser(testDb); + // createEphemeralToken is mocked to return undefined in this suite. + expect(authBridge.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 }); + }); + + it('AUTH-BR-006: stripUserForClient re-export strips secrets', () => { + const stripped = authBridge.stripUserForClient({ id: 1, password_hash: 'x' } as never); + expect(stripped).not.toHaveProperty('password_hash'); + }); + + it('AUTH-BR-007: avatarUrl re-export keeps the legacy shape', () => { + expect(authBridge.avatarUrl({ avatar: 'a.png' })).toBe('/uploads/avatars/a.png'); + expect(authBridge.avatarUrl({ avatar: null })).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index c1ee693be..28fdb7201 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -29,18 +29,20 @@ vi.mock('../../../src/app-config', async (importOriginal) => { return { ...actual, getAppUrl }; }); -const { resolveAuthToggles } = vi.hoisted(() => ({ resolveAuthToggles: vi.fn() })); -vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles })); +// OidcService injects AuthService since the auth DI fold; a stub instance +// replaces the old services/authService path mock. +const resolveAuthToggles = vi.fn(); const { setAuthCookie } = vi.hoisted(() => ({ setAuthCookie: vi.fn() })); vi.mock('../../../src/services/cookie', () => ({ setAuthCookie })); import { OidcService } from '../../../src/nest/oidc/oidc.service'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; let s: OidcService; beforeEach(() => { vi.clearAllMocks(); - s = new OidcService(); + s = new OidcService({ resolveAuthToggles } as unknown as AuthService); }); describe('OidcService', () => { diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index bb6b758b6..7cf7bb64b 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -56,7 +56,7 @@ import { createUser, createTrip } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { setupWebSocket } from '../../src/websocket'; import { createEphemeralToken } from '../../src/services/ephemeralTokens'; -import { createWsToken } from '../../src/services/authService'; +import { createWsToken } from '../../src/nest/auth/auth.bridge'; let server: http.Server; let wsUrl: string;