mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
+2
-3
File diff suppressed because one or more lines are too long
@@ -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,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,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,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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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,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,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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-11
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
+535
-98
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user