chore: move auth backend to proper DI

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.
This commit is contained in:
jubnl
2026-08-01 17:39:59 +02:00
parent 464fe5ff77
commit 45564b0a8a
60 changed files with 2551 additions and 1932 deletions
+2 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+34 -5
View File
@@ -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
@@ -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.');
@@ -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],
+1 -1
View File
@@ -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.
-29
View File
@@ -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<string> {
return atlas.getHiddenCountries(userId);
}
+4 -1
View File
@@ -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';
+1 -1
View File
@@ -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.
*/
+62
View File
@@ -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);
}
+181
View File
@@ -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<string, unknown> {
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 [];
}
}
+10 -1
View File
@@ -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 {}
File diff suppressed because it is too large Load Diff
@@ -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;
+11 -10
View File
@@ -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);
+2 -1
View File
@@ -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,
+12 -12
View File
@@ -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);
+2 -1
View File
@@ -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).
@@ -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 ──────────────────────────────────────────────────────────────
@@ -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],
+5 -5
View File
@@ -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);
+9 -8
View File
@@ -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 };
+2 -1
View File
@@ -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],
+2
View File
@@ -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],
})
+4 -2
View File
@@ -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); }
+16 -16
View File
@@ -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<string, unknown> = {};
@@ -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);
+2 -1
View File
@@ -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],
+10 -9
View File
@@ -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();
+2 -1
View File
@@ -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],
@@ -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);
@@ -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).
+4 -4
View File
@@ -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));
+2 -1
View File
@@ -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],
})
+5 -5
View File
@@ -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 });
+2
View File
@@ -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).
+8 -8
View File
@@ -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);
+2 -1
View File
@@ -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],
+3 -2
View File
@@ -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);
+2 -1
View File
@@ -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],
+8 -7
View File
@@ -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);
+2 -1
View File
@@ -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).
+21 -21
View File
@@ -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<string, never>, 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.');
}
+2
View File
@@ -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],
+1 -1
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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';
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -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';
// ---------------------------------------------------------------------------
+90 -51
View File
@@ -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<ReturnType<typeof build>>;
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);
+29 -6
View File
@@ -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<ReturnType<typeof build>>;
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' });
+17 -15
View File
@@ -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 },
+1 -1
View File
@@ -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
+8 -19
View File
@@ -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');
});
});
+7 -5
View File
@@ -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<typeof resolveAuthToggles>);
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<typeof resolveAuthToggles>);
resolveAuthToggles.mockReturnValue({ passkey_login: true });
expect(guard.canActivate()).toBe(true);
});
});
@@ -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 ────────────────────────────────────────────────────────────────
+5 -3
View File
@@ -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', () => {
+1 -1
View File
@@ -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;