import { readEnv } from '../../app-config'; import type { User } from '../../types'; import { AddonsService } from '../addons/addons.service'; import { logInfo } from '../audit/audit-log.logger'; import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { AdminGuard } from '../auth-core/admin.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { RegistrationInvitesService } from '../auth/registration-invites.service'; import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service'; import { ManagedForbidden } from '../common/managed'; import { ResponseContract } from '../common/response-contract'; import { NotificationsService } from '../notifications/notifications.service'; import { OauthService } from '../oauth/oauth.service'; import { PluginRuntimeService } from '../plugins/plugin-runtime.service'; import { TokenService } from '../tokens/token.service'; import { AdminUserCreateDto, AdminUserUpdateDto, AdminPermissionsDto, AdminInviteCreateDto, AdminFeatureToggleDto, AdminAddonUpdateDto, AdminCollabFeaturesDto, AdminTestNotificationDto, AdminTransitProviderDto, } from './admin.dto'; import { AdminService } from './admin.service'; import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards, } from '@nestjs/common'; import { adminFeatureToggleResponseSchema } from '@trek/shared'; import type { Request } from 'express'; /** * /api/admin — admin-only control surface (users, stats, permissions, audit log, * OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth * sessions, JWT rotation, default user settings). * * Byte-identical to the legacy Express route (server/src/routes/admin.ts): * admin-gated, the {error,status} envelopes, the audit-log writes, the MCP * session invalidation on addon/collab changes, create-201 vs the rest 200, and * the dev-only test-notification endpoint (404 outside development). * * Bodies validate via admin.dto.ts (@trek/shared schemas through the global * ZodValidationPipe) — malformed bodies now get the pipe's standard * { error: 'field: message; …' } envelope instead of the old inline typeof * checks (the sanctioned ratchet behavior); valid bodies behave identically, * and every bespoke service-level 400 is untouched. */ @Controller('api/admin') @UseGuards(JwtAuthGuard, AdminGuard) export class AdminController { constructor( private readonly admin: AdminService, private readonly addons: AddonsService, private readonly pluginRuntime: PluginRuntimeService, private readonly audit: AuditService, private readonly notifications: NotificationsService, private readonly tokens: TokenService, private readonly invites: RegistrationInvitesService, private readonly oauth: OauthService, private readonly kitinerary: KitineraryExtractorService, ) {} // ── Users ── @Get('users') async listUsers() { return { users: await this.admin.listUsers() }; } @Post('users') @HttpCode(201) async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) { const result = await this.admin.createUser(body as Parameters[0]); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails, }); return { user: result.user }; } @Put('users/:id') async updateUser( @CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request, ) { const result = await this.admin.updateUser(id, body); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed }, }); logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); return { user: result.user }; } @Delete('users/:id') async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = await this.admin.deleteUser(id, user.id); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email }, }); logInfo(`Admin ${user.email} deleted user ${result.email}`); return { success: true }; } @Delete('users/:id/passkeys') async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = await this.admin.resetUserPasskeys(id); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted }, }); return { success: true, deleted: result.deleted }; } @Delete('users/:id/mfa') async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { const result = await this.admin.resetUserMfa(id, user.id); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email }, }); return { success: true }; } // ── Stats / permissions / audit ── @Get('stats') stats() { return this.admin.getStats(); } @Get('permissions') permissions() { return this.admin.getPermissions(); } @Put('permissions') async savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) { const result = await this.admin.savePermissions( body.permissions as unknown as Parameters[0], ); await this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record, }); return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}), }; } @Get('audit-log') auditLog(@Query() query: { limit?: string; offset?: string }) { return this.admin.getAuditLog(query); } @Post('save-demo-baseline') @HttpCode(200) async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) { const result = await this.admin.saveDemoBaseline(); await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); return { success: true, message: result.message }; } // ── GitHub / version ── @Get('github-releases') async githubReleases(@Query('per_page') perPage = '10', @Query('page') page = '1') { return this.admin.getGithubReleases(String(perPage), String(page)); } @Get('version-check') async versionCheck() { return this.admin.checkVersion(); } /** * Versions of the bundled tools an operator cannot see from the UI. Today the * only one is the KItinerary extractor, whose age decides which booking * providers parse at all — and a stale one is indistinguishable from an * unsupported provider without this (#2261). */ @Get('system-info') systemInfo() { return { kitinerary: this.kitinerary.describe() }; } // ── Invites ── @Get('invites') async listInvites() { return { invites: await this.invites.listInvites() }; } // Trips an admin can optionally bind a registration invite to (#1402). @Get('invites/trips') async listInviteTrips() { return { trips: await this.invites.listTripsForInvite() }; } @Post('invites') @HttpCode(201) async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) { const result = await this.invites.createInvite(user.id, body); await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId }, }); return { invite: result.invite }; } @Delete('invites/:id') async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.invites.deleteInvite(id); await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req), }); return { success: true }; } // ── Feature toggles ── // Straight to AddonsService, which owns every one of these flags. They used to go // through eleven-plus pass-through methods on AdminService, and the three places // flags were raw app_settings SQL there even though the other flags lived in the // addons domain. The routes stay HERE rather than moving next to the service: an // admin-gated controller in AddonsModule would make that module import AuthModule, // and PluginGuardsModule imports AddonsModule precisely because it is a leaf — the // edge would close a cycle. @Get('bag-tracking') getBagTracking() { return this.addons.getBagTracking(); } @Put('bag-tracking') async updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updateBagTracking(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('tours-wanderer') @ResponseContract(adminFeatureToggleResponseSchema) getToursWanderer() { return this.addons.getToursWanderer(); } @Put('tours-wanderer') @ResponseContract(adminFeatureToggleResponseSchema) async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updateToursWanderer(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.tours_wanderer', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('tours-hiking-map') @ResponseContract(adminFeatureToggleResponseSchema) getToursHikingMap() { return this.addons.getToursHikingMap(); } @Put('tours-hiking-map') @ResponseContract(adminFeatureToggleResponseSchema) async updateToursHikingMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updateToursHikingMap(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.tours_hiking_map', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('places-photos') getPlacesPhotos() { return this.addons.getPlacesPhotos(); } @Put('places-photos') async updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlacesPhotos(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('places-autocomplete') getPlacesAutocomplete() { return this.addons.getPlacesAutocomplete(); } @Put('places-autocomplete') async updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlacesAutocomplete(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('places-details') getPlacesDetails() { return this.addons.getPlacesDetails(); } @Put('places-details') async updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlacesDetails(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('place-shadow') getPlaceShadow() { return this.addons.getPlaceShadow(); } @Put('place-shadow') async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlaceShadow(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('places-google-only') getPlacesGoogleOnly() { return this.addons.getPlacesGoogleOnly(); } @Put('places-google-only') async updatePlacesGoogleOnly(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlacesGoogleOnly(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.places_google_only', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('places-enrich') getPlacesEnrich() { return this.addons.getPlacesEnrich(); } @Put('places-enrich') async updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { const result = await this.addons.updatePlacesEnrich(body.enabled); await this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled }, }); return result; } @Get('collab-features') getCollabFeatures() { return this.addons.getCollabFeatures(); } @Put('collab-features') async updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) { const { features, changed } = await this.addons.updateCollabFeatures(body); // Collab flags gate MCP registration, but a no-op save must not tear down // every live MCP session (#1414). if (changed) this.admin.invalidateMcpSessions(); await this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features, }); return features; } // Which backend answers /api/transit (#1699). Transitous unless an admin // says otherwise, and still Transitous if Google is picked without a key. @Get('transit-provider') getTransitProvider(@CurrentUser() user: User) { return this.addons.getTransitProvider(user.id); } @Put('transit-provider') async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) { const result = await this.addons.updateTransitProvider(body.provider, user.id); await this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider }, }); return result; } // ── Addons ── @Get('addons') async listAddons() { return { addons: await this.admin.listAddons() }; } @Put('addons/:id') async updateAddon( @CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request, ) { const result = await this.admin.updateAddon(id, body); await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails, }); // Sessions only need re-creating when the registered MCP surface can // actually change — an enabled-flip of an MCP-relevant addon. Config-only // saves and photo-provider toggles used to kill every session (#1414). if (result.mcpAffected) this.admin.invalidateMcpSessions(); // Disabling an addon cascades to plugins that require it: stop them (and any // plugin that transitively depends on them) so a plugin never runs against a // disabled addon (#plugins dependencies). if (result.addon && result.addon.enabled === false && (body as { enabled?: boolean })?.enabled === false) { const stopped = await this.pluginRuntime.deactivateForDisabledAddon(id); // A stopped plugin takes its MCP tools off the surface, and the addon that // cascaded here is not necessarily an MCP-relevant one, so mcpAffected // above does not cover this. Still conditional on something actually // stopping, which keeps the #1414 rule that a no-op save kills nothing. if (stopped.length && !result.mcpAffected) this.admin.invalidateMcpSessions(); } return { addon: result.addon }; } // ── MCP tokens / OAuth sessions ── @Get('mcp-tokens') async listMcpTokens() { return { tokens: await this.tokens.listAllMcpTokens() }; } @Delete('mcp-tokens/:id') async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.tokens.adminDeleteMcpToken(id); await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req), }); return { success: true }; } @Get('oauth-sessions') async listOAuthSessions() { return { sessions: await this.oauth.listAllOAuthSessions() }; } @Delete('oauth-sessions/:id') async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { await this.oauth.adminRevokeOAuthSession(id); await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req), }); return { success: true }; } // ── JWT rotation ── @ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach') @Post('rotate-jwt-secret') @HttpCode(200) async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) { this.admin.rotateJwtSecret(); await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) }); return { success: true }; } // ── Default user settings ── // ── Dev-only: test notification (404 outside development, mirroring the conditional mount) ── @Post('dev/test-notification') @HttpCode(200) async devTestNotification(@CurrentUser() user: User, @Body() body: AdminTestNotificationDto) { if (!readEnv().app.isDevelopment) { throw new NotFoundException(); } try { await this.notifications.send({ event: body.event ?? 'trip_reminder', actorId: user.id, scope: body.scope ?? 'user', targetId: body.targetId ?? user.id, params: { actor: user.email, ...(body.params ?? {}) }, inApp: body.inApp, } as unknown as Parameters[0]); return { success: true }; } catch (err) { throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); } } }