refactor(auth, files): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 01:08:22 +02:00
parent 4df2d83137
commit 7940b7ff84
82 changed files with 1020 additions and 990 deletions
@@ -68,7 +68,7 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
@@ -119,7 +119,7 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const dayErrors = this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
@@ -167,7 +167,7 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.accommodations.getAccommodation(accommodationId, tripId);
@@ -191,7 +191,7 @@ export class AccommodationsMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
+5 -5
View File
@@ -103,7 +103,7 @@ export class AdminController {
@Delete('users/:id/passkeys')
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserPasskeys(id));
const result = ok(await this.admin.resetUserPasskeys(id));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
return { success: true, deleted: result.deleted };
}
@@ -163,23 +163,23 @@ export class AdminController {
// ── Invites ──
@Get('invites')
listInvites() { return { invites: this.invites.listInvites() }; }
async listInvites() { return { invites: await this.invites.listInvites() }; }
// Trips an admin can optionally bind a registration invite to (#1402).
@Get('invites/trips')
listInviteTrips() { return { trips: this.invites.listTripsForInvite() }; }
async listInviteTrips() { return { trips: await this.invites.listTripsForInvite() }; }
@Post('invites')
@HttpCode(201)
async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
const result = this.invites.createInvite(user.id, body);
const result = await this.invites.createInvite(user.id, body);
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
return { invite: result.invite };
}
@Delete('invites/:id')
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.invites.deleteInvite(id));
ok(await this.invites.deleteInvite(id));
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -46,7 +46,7 @@ export class AssignmentsMcp {
{ tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.');
@@ -72,7 +72,7 @@ export class AssignmentsMcp {
{ tripId, assignmentId, end_day }: AssignmentEndDayRequest & { tripId: number; assignmentId: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
@@ -96,7 +96,7 @@ export class AssignmentsMcp {
{ tripId, dayId, assignmentId }: { tripId: number; dayId: number; assignmentId: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId))
@@ -125,7 +125,7 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
@@ -161,7 +161,7 @@ export class AssignmentsMcp {
{ tripId, assignmentId, notes }: { tripId: number; assignmentId: number; notes: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
@@ -188,7 +188,7 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
@@ -218,7 +218,7 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
@@ -267,7 +267,7 @@ export class AssignmentsMcp {
{ tripId, assignmentId, userIds }: { tripId: number; assignmentId: number; userIds: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
@@ -291,7 +291,7 @@ export class AssignmentsMcp {
{ tripId, dayId, assignmentIds }: { tripId: number; dayId: number; assignmentIds: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
+7 -7
View File
@@ -86,7 +86,7 @@ export class AtlasMcp {
{ name, lat, lng, country_code, notes, target_date }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
try {
const item = await this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date });
return ok({ item });
@@ -107,7 +107,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async deleteBucketListItem({ itemId }: { itemId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const deleted = await this.atlas.deleteBucketItem(ctx.userId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
@@ -126,7 +126,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.atlas.markCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -142,7 +142,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -226,7 +226,7 @@ export class AtlasMcp {
{ regionCode, regionName, countryCode }: { regionCode: string; regionName: string; countryCode: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Post-fold quirk fix: uppercase both codes, matching the REST controller
// (the legacy registrar passed them through verbatim, so a lowercase mark
// created a row REST's uppercased unmark could never hit).
@@ -251,7 +251,7 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Post-fold quirk fix: uppercase, matching the REST controller.
await this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase());
return ok({ success: true });
@@ -302,7 +302,7 @@ export class AtlasMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
let item: unknown;
try {
item = await this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
@@ -45,8 +45,8 @@ export class AuthPublicController {
@Post('demo-login')
@Public('issues a session for the demo account; there is nothing to authenticate yet')
@HttpCode(200)
demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const result = this.auth.demoLogin();
async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const result = await this.auth.demoLogin();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -56,9 +56,9 @@ export class AuthPublicController {
@Get('invite/:token')
@Public('the invite token IS the credential')
invite(@Param('token') token: string, @Req() req: Request) {
async invite(@Param('token') token: string, @Req() req: Request) {
this.limit('login', req, 10);
const result = this.auth.validateInviteToken(token);
const result = await this.auth.validateInviteToken(token);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -85,7 +85,7 @@ export class AuthPublicController {
async login(@Body() body: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
const started = Date.now();
const result = this.auth.loginUser(body);
const result = await this.auth.loginUser(body);
if (result.auditAction) {
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
@@ -116,7 +116,7 @@ export class AuthPublicController {
const rawEmail = typeof body?.email === 'string' ? body.email : '';
const ip = getClientIp(req);
const outcome = this.auth.requestPasswordReset(rawEmail, ip);
const outcome = await this.auth.requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
const origin = this.auth.getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
@@ -143,7 +143,7 @@ export class AuthPublicController {
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
this.limit('reset', req, 5);
const ip = getClientIp(req);
const result = this.auth.resetPassword(body);
const result = await this.auth.resetPassword(body);
if (result.error) {
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
throw new HttpException({ error: result.error }, result.status!);
@@ -160,7 +160,7 @@ export class AuthPublicController {
@HttpCode(200)
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('mfa', req, 5);
const result = this.auth.verifyMfaLogin(body);
const result = await this.auth.verifyMfaLogin(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+11 -11
View File
@@ -98,8 +98,8 @@ export class AuthController {
@Get('me')
@MfaExempt('the client needs to know who it is to render the setup screen')
me(@CurrentUser() user: User) {
const loaded = this.auth.getCurrentUser(user.id);
async me(@CurrentUser() user: User) {
const loaded = await this.auth.getCurrentUser(user.id);
if (!loaded) {
throw new HttpException({ error: 'User not found' }, 404);
}
@@ -113,7 +113,7 @@ export class AuthController {
// "remember me" login survives a password change (#1927). Bearer callers
// have no cookie → undefined → the historical default duration.
const remember = decodeSessionClaims((req.cookies as Record<string, string> | undefined)?.trek_session)?.remember;
const result = this.auth.changePassword(user.id, user.email, body, remember);
const result = await this.auth.changePassword(user.id, user.email, body, remember);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -213,8 +213,8 @@ export class AuthController {
}
@Get('users')
users(@CurrentUser() user: User) {
return { users: this.profile.listUsers(user.id) };
async users(@CurrentUser() user: User) {
return { users: await this.profile.listUsers(user.id) };
}
@ManagedForbidden('validating a key spends the operator quota on a test click')
@@ -229,8 +229,8 @@ export class AuthController {
@Get('app-settings')
@MfaExempt('the setup screen reads the policy it is asking the user to satisfy')
getAppSettings(@CurrentUser() user: User) {
const result = this.auth.getAppSettings(user.id);
async getAppSettings(@CurrentUser() user: User) {
const result = await this.auth.getAppSettings(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -240,7 +240,7 @@ export class AuthController {
@Put('app-settings')
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
const result = this.auth.updateAppSettings(user.id, body);
const result = await this.auth.updateAppSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -258,7 +258,7 @@ export class AuthController {
@MfaExempt('completing setup is the way out of the policy')
@HttpCode(200)
async mfaSetup(@CurrentUser() user: User) {
const result = this.auth.setupMfa(user.id, user.email);
const result = await this.auth.setupMfa(user.id, user.email);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -276,7 +276,7 @@ export class AuthController {
@HttpCode(200)
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
this.limit('mfa', req, 5);
const result = this.auth.enableMfa(user.id, body.code);
const result = await this.auth.enableMfa(user.id, body.code);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -288,7 +288,7 @@ export class AuthController {
@HttpCode(200)
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.auth.disableMfa(user.id, user.email, body);
const result = await this.auth.disableMfa(user.id, user.email, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+45 -45
View File
@@ -148,13 +148,13 @@ export class AuthService {
// Toggles + tokens
// -------------------------------------------------------------------------
resolveAuthToggles(): {
async resolveAuthToggles(): Promise<{
password_login: boolean;
password_registration: boolean;
oidc_login: boolean;
oidc_registration: boolean;
passkey_login: boolean;
} {
}> {
const get = (key: string) =>
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key)?.value ?? null;
@@ -200,11 +200,11 @@ export class AuthService {
};
}
isOidcOnlyMode(): boolean {
return !this.resolveAuthToggles().password_login;
async isOidcOnlyMode(): Promise<boolean> {
return !(await this.resolveAuthToggles()).password_login;
}
generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) {
async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) {
const pv = typeof user.password_version === 'number'
? user.password_version
: (this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', user.id)?.password_version ?? 0);
@@ -244,8 +244,8 @@ export class AuthService {
* the operator pointed here themselves (APP_URL, ALLOWED_ORIGINS or the
* webauthn settings) is a real single-machine install and still counts.
*/
private passkeyConfigured(): boolean {
const cfg = this.webauthn.resolve();
private async passkeyConfigured(): Promise<boolean> {
const cfg = await this.webauthn.resolve();
if (!cfg) return false;
if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true;
const env = readEnv();
@@ -258,7 +258,7 @@ export class AuthService {
async getAppConfig(authenticatedUser: User | undefined | null) {
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const isDemo = readEnv().demo.enabled;
const toggles = this.resolveAuthToggles();
const toggles = await this.resolveAuthToggles();
// One directory deeper than the legacy src/services location — the extra
// '../' keeps resolving to the workspace package.json.
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
@@ -317,7 +317,7 @@ export class AuthService {
// are true. `passkey_configured` stays a pure boolean — it never leaks the
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
passkey_login: toggles.passkey_login,
passkey_configured: this.passkeyConfigured(),
passkey_configured: await this.passkeyConfigured(),
env_override_oidc_only: readEnv().oidc.only,
has_users: userCount > 0,
setup_complete: setupComplete,
@@ -333,7 +333,7 @@ export class AuthService {
// fallback the upload filters use, so the client's picker and the
// server's acceptance can never drift (the historical inline copy here
// dropped pkpass, pkpasses, md and markdown).
allowed_file_types: this.allowedFileTypes.get(),
allowed_file_types: await this.allowedFileTypes.get(),
// Whether the configuration belongs to whoever operates this install
// rather than to its admin. The client uses it to stop offering settings
// the server would refuse anyway — it is an honesty flag for the UI, never
@@ -362,18 +362,18 @@ export class AuthService {
// Auth: register, login, demo
// -------------------------------------------------------------------------
demoLogin(): { error?: string; status?: number; token?: string; user?: Record<string, unknown> } {
async demoLogin(): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown> }> {
if (!readEnv().demo.enabled) {
return { error: 'Not found', status: 404 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE email = ?', DEMO_EMAIL_PRIMARY);
if (!user) return { error: 'Demo user not found', status: 500 };
const token = this.generateToken(user);
const token = await this.generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
}
validateInviteToken(token: string): { error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string } {
async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string }> {
const invite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', token) as any;
if (!invite) return { error: 'Invalid invite link', status: 404 };
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
@@ -398,7 +398,7 @@ export class AuthService {
}
if (userCount > 0 && !validInvite) {
const toggles = this.resolveAuthToggles();
const toggles = await this.resolveAuthToggles();
if (!toggles.password_registration) {
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
}
@@ -435,7 +435,7 @@ export class AuthService {
);
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
const token = this.generateToken(user);
const token = await this.generateToken(user);
if (validInvite) {
const updated = this.db.get(
@@ -464,7 +464,7 @@ export class AuthService {
}
}
loginUser(rawBody: unknown): {
async loginUser(rawBody: unknown): Promise<{
error?: string;
status?: number;
token?: string;
@@ -475,9 +475,9 @@ export class AuthService {
auditUserId?: number | null;
auditAction?: string;
auditDetails?: Record<string, unknown>;
} {
}> {
const body = rawBody as { email?: string; password?: string; remember_me?: boolean };
if (this.isOidcOnlyMode()) {
if (await this.isOidcOnlyMode()) {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
@@ -526,7 +526,7 @@ export class AuthService {
}
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
const token = this.generateToken(user, remember);
const token = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
@@ -543,9 +543,9 @@ export class AuthService {
// Session
// -------------------------------------------------------------------------
getCurrentUser(
async getCurrentUser(
userId: number
): (Record<string, unknown> & Pick<User, 'id' | 'username' | 'email' | 'role'> & { avatar_url: string }) | null {
): Promise<(Record<string, unknown> & Pick<User, 'id' | 'username' | 'email' | 'role'> & { avatar_url: string }) | null> {
const user = this.db.get<User>(
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?',
userId
@@ -559,14 +559,14 @@ export class AuthService {
// Password & account
// -------------------------------------------------------------------------
changePassword(
async changePassword(
userId: number,
userEmail: string,
rawBody: unknown,
remember?: boolean,
): { error?: string; status?: number; success?: boolean; token?: string } {
): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> {
const body = rawBody as { current_password?: string; new_password?: string };
if (this.isOidcOnlyMode()) {
if (await this.isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
@@ -588,7 +588,7 @@ export class AuthService {
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
const newPv = (user.password_version ?? 0) + 1;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', hash, newPv, userId);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
@@ -606,7 +606,7 @@ export class AuthService {
// stays logged in while other existing sessions are rotated out by the pv
// gate — preserving the login's remember choice instead of downgrading a
// remembered session to the default duration (#1927).
const token = this.generateToken({ id: userId, password_version: newPv }, remember);
const token = await this.generateToken({ id: userId, password_version: newPv }, remember);
return { success: true, token };
}
@@ -639,7 +639,7 @@ export class AuthService {
// work, not with a move.
// -------------------------------------------------------------------------
getAppSettings(userId: number): { error?: string; status?: number; data?: Record<string, string> } {
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
@@ -651,10 +651,10 @@ export class AuthService {
return { data: result };
}
updateAppSettings(
async updateAppSettings(
userId: number,
rawBody: unknown
): {
): Promise<{
error?: string;
status?: number;
success?: boolean;
@@ -662,7 +662,7 @@ export class AuthService {
auditDebugDetails?: Record<string, unknown>;
/** Names the operator holds, skipped rather than written. Empty when self-hosted. */
managedKeys?: string[];
} {
}> {
const body = rawBody as Record<string, unknown>;
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
@@ -683,7 +683,7 @@ export class AuthService {
// Lockout prevention: can't disable all login methods
if (body.password_login !== undefined || body.oidc_login !== undefined) {
const current = this.resolveAuthToggles();
const current = await this.resolveAuthToggles();
const oidcConfigured = !!(
(readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) &&
(readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value)
@@ -748,7 +748,7 @@ export class AuthService {
// MFA
// -------------------------------------------------------------------------
setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> } {
async setupMfa(userId: number, userEmail: string): Promise<{ error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
@@ -768,7 +768,7 @@ export class AuthService {
return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) };
}
enableMfa(userId: number, rawCode: unknown): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } {
async enableMfa(userId: number, rawCode: unknown): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
const code = rawCode as string | undefined;
if (!code) {
return { error: 'Verification code is required', status: 400 };
@@ -794,11 +794,11 @@ export class AuthService {
return { success: true, mfa_enabled: true, backup_codes: backupCodes };
}
disableMfa(
async disableMfa(
userId: number,
userEmail: string,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } {
): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> {
const body = rawBody as { password?: string; code?: string };
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
@@ -831,14 +831,14 @@ export class AuthService {
return { success: true, mfa_enabled: false };
}
verifyMfaLogin(rawBody: unknown): {
async verifyMfaLogin(rawBody: unknown): Promise<{
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
remember?: boolean;
auditUserId?: number;
} {
}> {
const body = rawBody as { mfa_token?: string; code?: string; remember_me?: boolean };
const { mfa_token, code, remember_me } = body;
const remember = remember_me === true;
@@ -868,7 +868,7 @@ export class AuthService {
hashes.splice(idx, 1);
// Consume the backup code and record the login atomically — the code
// must not burn without the login landing (or vice versa).
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
JSON.stringify(hashes),
user.id
@@ -878,7 +878,7 @@ export class AuthService {
} else {
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
}
const sessionToken = this.generateToken(user, remember);
const sessionToken = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token: sessionToken,
@@ -895,7 +895,7 @@ export class AuthService {
// Password reset
// -------------------------------------------------------------------------
requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome {
async requestPasswordReset(rawEmail: string, createdIp: string | null): Promise<PasswordResetRequestOutcome> {
const email = String(rawEmail || '').trim().toLowerCase();
// Basic shape check — a fully empty / malformed email is treated like
// "no user" so we still spend the same time internally. Same "x@y.z somewhere
@@ -904,7 +904,7 @@ export class AuthService {
const looksLikeEmail = email.length > 0 && /^.[^@\r\n\u2028\u2029]*@.[^.\r\n\u2028\u2029]*\../m.test(email);
// Global policy check: password login disabled → no reset possible.
const toggles = this.resolveAuthToggles();
const toggles = await this.resolveAuthToggles();
if (!toggles.password_login) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' };
}
@@ -971,7 +971,7 @@ export class AuthService {
* compromised email alone therefore does NOT allow taking over a
* 2FA-protected account.
*/
resetPassword(rawBody: unknown): ResetPasswordOutcome {
async resetPassword(rawBody: unknown): Promise<ResetPasswordOutcome> {
const body = rawBody as { token?: string; new_password?: string; mfa_code?: string };
const { token, new_password, mfa_code } = body;
if (!token || typeof token !== 'string') {
@@ -1028,7 +1028,7 @@ export class AuthService {
const newHash = bcrypt.hashSync(new_password, BCRYPT_COST);
const newPv = (user.password_version ?? 0) + 1;
this.db.transaction(() => {
await this.uow.transactional(async () => {
// Burn the token first to keep it atomic with the password change.
this.db.run('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
// Also burn every OTHER live token for this user — a fresh login
@@ -1074,7 +1074,7 @@ export class AuthService {
// which is about minting a token.
// -------------------------------------------------------------------------
isDemoUser(userId: number): boolean {
async isDemoUser(userId: number): Promise<boolean> {
if (!readEnv().demo.enabled) return false;
const user = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return isDemoEmail(user?.email);
@@ -1089,7 +1089,7 @@ export class AuthService {
* (MCP bearer, WebSocket handshake, file-download query tokens, photo
* route) should go through.
*/
verifyJwtToken(token: string): User | null {
async verifyJwtToken(token: string): Promise<User | null> {
return verifyJwtAndLoadUser(token);
}
}
+2 -2
View File
@@ -10,13 +10,13 @@ import { verifyJwtAndLoadUser } from './jwt-verify';
*/
@Injectable()
export class CookieAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request & { cookies?: Record<string, string> }>();
const cookieToken = req.cookies?.trek_session;
if (!cookieToken) {
throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(cookieToken);
const user = await verifyJwtAndLoadUser(cookieToken);
if (!user) {
throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401);
}
+4 -4
View File
@@ -30,7 +30,7 @@ import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator';
export class GlobalAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controller = context.getClass();
@@ -40,7 +40,7 @@ export class GlobalAuthGuard implements CanActivate {
if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) {
const token = extractToken(req);
(req as { user: unknown }).user = (token ? verifyJwtAndLoadUser(token) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
return true;
}
@@ -57,7 +57,7 @@ export class GlobalAuthGuard implements CanActivate {
];
if (declared.length > 0) {
const declaredToken = extractToken(req);
(req as { user: unknown }).user = (declaredToken ? verifyJwtAndLoadUser(declaredToken) : null) || null;
(req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken) : null) || null;
return true;
}
@@ -65,7 +65,7 @@ export class GlobalAuthGuard implements CanActivate {
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(token);
const user = await verifyJwtAndLoadUser(token);
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+2 -2
View File
@@ -12,13 +12,13 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(token);
const user = await verifyJwtAndLoadUser(token);
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function decodeSessionClaims(token: string | undefined): SessionClaims |
return decoded as SessionClaims;
}
export function verifyJwtAndLoadUser(token: string): User | null {
export async function verifyJwtAndLoadUser(token: string): Promise<User | null> {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
+1 -1
View File
@@ -49,7 +49,7 @@ export class MfaPolicyGuard implements CanActivate {
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controller = context.getClass();
+2 -2
View File
@@ -10,10 +10,10 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify';
*/
@Injectable()
export class OptionalJwtGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
(req as { user: unknown }).user = (token ? verifyJwtAndLoadUser(token) : null) || null;
(req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null;
return true;
}
}
@@ -15,8 +15,8 @@ import { AuthService } from './auth.service';
export class PasskeyEnabledGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
canActivate(): boolean {
if (!this.auth.resolveAuthToggles().passkey_login) {
async canActivate(): Promise<boolean> {
if (!(await this.auth.resolveAuthToggles()).passkey_login) {
throw new HttpException({ error: 'Passkey login is not enabled' }, 404);
}
return true;
+5 -5
View File
@@ -104,14 +104,14 @@ export class PasskeyController {
@Get('credentials')
@MfaExempt('the setup screen lists what the user already has')
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: User) {
return { credentials: this.passkeys.listPasskeys(user.id) };
async list(@CurrentUser() user: User) {
return { credentials: await this.passkeys.listPasskeys(user.id) };
}
@Patch('credentials/:id')
@UseGuards(JwtAuthGuard)
rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
const result = this.passkeys.renamePasskey(user.id, id, body?.name);
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
const result = await this.passkeys.renamePasskey(user.id, id, body?.name);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return { success: true };
}
@@ -120,7 +120,7 @@ export class PasskeyController {
@UseGuards(JwtAuthGuard)
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.passkeys.deletePasskey(user.id, id, body?.password);
const result = await this.passkeys.deletePasskey(user.id, id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
+27 -26
View File
@@ -12,6 +12,7 @@ import { avatarUrl } from '../common/avatarUrl';
import { stripUserForClient } from './auth.helpers';
import { AuthService } from './auth.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import type { User } from '../../types';
// ---------------------------------------------------------------------------
@@ -107,17 +108,18 @@ export class PasskeyService {
private readonly db: DatabaseService,
private readonly auth: AuthService,
private readonly webauthn: WebauthnConfigService,
private readonly uow: UnitOfWork,
) {}
// -------------------------------------------------------------------------
// Challenge store (DB-backed, single-use, TTL'd)
// -------------------------------------------------------------------------
private purgeExpiredChallenges(now: number): void {
private async purgeExpiredChallenges(now: number): Promise<void> {
this.db.run('DELETE FROM webauthn_challenges WHERE expires_at < ?', now);
}
private storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void {
private async storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): Promise<void> {
this.db.run(
'INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)',
challenge, userId, type, now + CHALLENGE_TTL_MS,
@@ -130,7 +132,7 @@ export class PasskeyService {
* concurrent double-submit of the same assertion can never spend one challenge
* twice (the replay window a SELECT→await→DELETE ordering would open).
*/
private claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null {
private async claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): Promise<{ user_id: number | null } | null> {
const row = this.db.get<{ user_id: number | null }>(
'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id',
challenge, type, now,
@@ -203,7 +205,7 @@ export class PasskeyService {
password: string | undefined,
requestOrigin?: string,
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
const cfg = this.webauthn.resolve();
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
@@ -222,7 +224,7 @@ export class PasskeyService {
);
const now = Date.now();
this.purgeExpiredChallenges(now);
await this.purgeExpiredChallenges(now);
const options = await generateRegistrationOptions({
rpName: cfg.rpName,
@@ -237,7 +239,7 @@ export class PasskeyService {
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
this.storeChallenge(options.challenge, userId, 'registration', now);
await this.storeChallenge(options.challenge, userId, 'registration', now);
return { options };
}
@@ -245,7 +247,7 @@ export class PasskeyService {
userId: number,
body: { attestationResponse?: unknown; name?: unknown },
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
const cfg = this.webauthn.resolve();
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.attestationResponse;
@@ -255,7 +257,7 @@ export class PasskeyService {
if (!challenge) return { error: 'Invalid registration response', status: 400 };
const now = Date.now();
const claimed = this.claimChallenge(challenge, 'registration', now);
const claimed = await this.claimChallenge(challenge, 'registration', now);
if (!claimed || claimed.user_id !== userId) {
return { error: 'Registration challenge expired. Please try again.', status: 400 };
}
@@ -291,15 +293,14 @@ export class PasskeyService {
// Duplicate check + INSERT in one transaction so the UNIQUE race can't slip
// between them; the sentinel keeps the legacy 409-vs-400 split intact.
try {
this.db.transaction((conn) => {
if (conn.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) {
await this.uow.transactional(async () => {
if (this.db.get('SELECT id FROM webauthn_credentials WHERE credential_id = ?', credential.id)) {
throw DUPLICATE_CREDENTIAL;
}
conn.prepare(
this.db.run(
`INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
).run(
userId,
credential.id,
Buffer.from(credential.publicKey),
@@ -334,12 +335,12 @@ export class PasskeyService {
status?: number;
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
}> {
const cfg = this.webauthn.resolve();
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
const now = Date.now();
this.purgeExpiredChallenges(now);
await this.purgeExpiredChallenges(now);
const options = await generateAuthenticationOptions({
rpID: cfg.rpID,
@@ -348,7 +349,7 @@ export class PasskeyService {
// accounts have passkeys, so the endpoint can't be used to enumerate users.
});
this.storeChallenge(options.challenge, null, 'authentication', now);
await this.storeChallenge(options.challenge, null, 'authentication', now);
return { options };
}
@@ -360,7 +361,7 @@ export class PasskeyService {
auditUserId?: number | null;
auditAction?: string;
}> {
const cfg = this.webauthn.resolve();
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
const resp = body?.assertionResponse;
@@ -371,7 +372,7 @@ export class PasskeyService {
// Claim the challenge (single-use) BEFORE looking anything up or verifying.
const now = Date.now();
if (!this.claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED };
if (!(await this.claimChallenge(challenge, 'authentication', now))) return { ...AUTH_FAILED };
const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId;
if (typeof credId !== 'string') return { ...AUTH_FAILED };
@@ -417,15 +418,15 @@ export class PasskeyService {
if (!user) return { ...AUTH_FAILED };
// Persist the new counter + last-used and bump login bookkeeping atomically.
this.db.transaction((conn) => {
conn.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id);
conn.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
await this.uow.transactional(async () => {
this.db.run('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?', newCounter, cred.id);
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
});
// A user-verified passkey is phishing-resistant and inherently two-factor
// (device possession + biometric/PIN), so it mints the real session directly
// — the SAME path as password and OIDC login (no new token shape).
const token = this.auth.generateToken(user);
const token = await this.auth.generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) };
}
@@ -434,7 +435,7 @@ export class PasskeyService {
// Management (authenticated, owner-scoped)
// -------------------------------------------------------------------------
listPasskeys(userId: number): Array<Record<string, unknown>> {
async listPasskeys(userId: number): Promise<Array<Record<string, unknown>>> {
const rows = this.db.all<{ backed_up: number } & Record<string, unknown>>(
'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC',
userId,
@@ -442,7 +443,7 @@ export class PasskeyService {
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
}
renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } {
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ error?: string; status?: number; success?: boolean }> {
const cleanName = sanitizeName(name);
if (!cleanName) return { error: 'Name is required', status: 400 };
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
@@ -451,11 +452,11 @@ export class PasskeyService {
return { success: true };
}
deletePasskey(
async deletePasskey(
userId: number,
id: string,
password: string | undefined,
): { error?: string; status?: number; success?: boolean } {
): Promise<{ error?: string; status?: number; success?: boolean }> {
// Re-auth before removing a credential (a hijacked session must not be able to
// strip the victim's passkeys). Deleting is always allowed because every
// account keeps a usable password as recovery fallback — losing all passkeys
@@ -470,7 +471,7 @@ export class PasskeyService {
}
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } {
async adminResetPasskeys(targetUserId: number): Promise<{ error?: string; status?: number; success?: boolean; deleted?: number; email?: string }> {
const target = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', targetUserId);
if (!target) return { error: 'User not found', status: 404 };
const result = this.db.run('DELETE FROM webauthn_credentials WHERE user_id = ?', targetUserId);
@@ -21,7 +21,7 @@ import { DatabaseService } from '../database/database.service';
export class RegistrationInvitesService {
constructor(private readonly db: DatabaseService) {}
listInvites() {
async listInvites() {
return this.db.all(`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
@@ -32,11 +32,11 @@ export class RegistrationInvitesService {
}
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
listTripsForInvite() {
async listTripsForInvite() {
return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC');
}
createInvite(
async createInvite(
createdBy: number,
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
) {
@@ -77,7 +77,7 @@ export class RegistrationInvitesService {
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
}
deleteInvite(id: string) {
async deleteInvite(id: string) {
const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id);
if (!invite) return { error: 'Invite not found', status: 404 };
this.db.run('DELETE FROM invite_tokens WHERE id = ?', id);
@@ -33,16 +33,16 @@ import { setAuthCookie } from '../common/cookie';
export class SessionRenewalInterceptor implements NestInterceptor {
constructor(private readonly auth: AuthService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
if (context.getType() === 'http') {
const req = context.switchToHttp().getRequest<Request & { user?: { id: number } }>();
const res = context.switchToHttp().getResponse<Response>();
this.maybeRenew(req, res);
await this.maybeRenew(req, res);
}
return next.handle();
}
private maybeRenew(req: Request & { user?: { id: number } }, res: Response): void {
private async maybeRenew(req: Request & { user?: { id: number } }, res: Response): Promise<void> {
const cookieToken: string | undefined = (req as { cookies?: Record<string, string> }).cookies?.trek_session;
if (!req.user || !cookieToken || res.headersSent) return;
@@ -53,7 +53,7 @@ export class SessionRenewalInterceptor implements NestInterceptor {
const halfLife = claims.iat + (claims.exp - claims.iat) / 2;
if (Date.now() / 1000 < halfLife) return;
const token = this.auth.generateToken(
const token = await this.auth.generateToken(
{ id: req.user.id, password_version: claims.pv ?? 0 },
claims.remember,
);
+2 -2
View File
@@ -39,7 +39,7 @@ export class UserCleanupService {
* Best-effort per table so a slimmed-down schema (some tests) can't fail the user
* deletion itself.
*/
erasePluginUserData(userId: number): void {
async erasePluginUserData(userId: number): Promise<void> {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
try { this.db.run(`DELETE FROM ${table} WHERE user_id = ?`, userId); } catch { /* table absent (slim schema) */ }
}
@@ -81,7 +81,7 @@ export class UserCleanupService {
async deleteUserCompletely(userId: number): Promise<void> {
await this.uow.transactional(async () => {
await this.cleanupUserReferences(userId);
this.erasePluginUserData(userId);
await this.erasePluginUserData(userId);
this.db.run('DELETE FROM users WHERE id = ?', userId);
});
}
+5 -5
View File
@@ -54,7 +54,7 @@ export class UserProfileService {
}
/** The three key columns plus the role that decides where a save lands. */
private currentKeys(userId: number) {
private async currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
@@ -128,7 +128,7 @@ export class UserProfileService {
if (this.managed) {
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
}
const current = this.currentKeys(userId);
const current = await this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = await this.changedKeyNames({ maps_api_key }, current, isAdmin);
await this.uow.transactional(async () => {
@@ -145,7 +145,7 @@ export class UserProfileService {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.currentKeys(userId);
const current = await this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = await this.changedKeyNames(body, current, isAdmin, blocked);
@@ -222,7 +222,7 @@ export class UserProfileService {
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
const current = this.currentKeys(userId);
const current = await this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = keyLocked ? [] : await this.changedKeyNames(body, current, isAdmin, blocked);
@@ -322,7 +322,7 @@ export class UserProfileService {
// User directory
// -------------------------------------------------------------------------
listUsers(excludeUserId: number) {
async listUsers(excludeUserId: number) {
// The global user directory feeds the trip member-add / contributor pickers —
// guests (#1362) are trip-scoped and must never be selectable here.
const users = this.db.all<Pick<User, 'id' | 'username' | 'avatar'>>(
@@ -70,7 +70,7 @@ export interface WebauthnConfig {
export class WebauthnConfigService {
constructor(private readonly db: DatabaseService) {}
private setting(key: string): string | null {
private async setting(key: string): Promise<string | null> {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value;
const trimmed = raw?.trim();
return trimmed ? trimmed : null;
@@ -81,10 +81,10 @@ export class WebauthnConfigService {
* configured) — the feature then reports itself as "not configured" and stays
* disabled so nobody can enrol a credential bound to the wrong origin.
*/
resolve(): WebauthnConfig | null {
async resolve(): Promise<WebauthnConfig | null> {
// 1. Explicit operator config always wins.
const explicitRpId = (readEnv().webauthn.rpId || this.setting('webauthn_rp_id'))?.trim() || null;
const explicitOrigins = (readEnv().webauthn.origins || this.setting('webauthn_origins') || '')
const explicitRpId = (readEnv().webauthn.rpId || (await this.setting('webauthn_rp_id')))?.trim() || null;
const explicitOrigins = (readEnv().webauthn.origins || (await this.setting('webauthn_origins')) || '')
.split(',')
// The lookbehind matches only the first slash of the trailing run. Without it the
// engine retries from every slash, which is quadratic on a slash-heavy value.
@@ -118,7 +118,7 @@ export class WebauthnConfigService {
}
/** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */
isConfigured(): boolean {
return this.resolve() !== null;
async isConfigured(): Promise<boolean> {
return (await this.resolve()) !== null;
}
}
+10 -10
View File
@@ -94,7 +94,7 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned });
@@ -125,7 +125,7 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned });
@@ -146,7 +146,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.collab.deleteNote(tripId, noteId);
@@ -193,7 +193,7 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline });
@@ -214,7 +214,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex);
@@ -235,7 +235,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = this.collab.closePoll(tripId, pollId);
@@ -256,7 +256,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.collab.deletePoll(tripId, pollId);
@@ -295,7 +295,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null);
@@ -316,7 +316,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.deleteMessage(tripId, messageId, ctx.userId);
@@ -338,7 +338,7 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji);
+25 -25
View File
@@ -62,8 +62,8 @@ export class CollectionsMcp {
readonly addons: AddonsService,
) {}
private denyDemo(userId: number) {
return this.auth.isDemoUser(userId) ? demoDenied() : null;
private async denyDemo(userId: number) {
return (await this.auth.isDemoUser(userId)) ? demoDenied() : null;
}
// ── Read ──────────────────────────────────────────────────────────────
@@ -144,7 +144,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async createCollection(body: CollectionCreateRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ collection: this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); }
}
@@ -157,7 +157,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollection({ collectionId, ...body }: { collectionId: number } & CollectionUpdateRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ collection: this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); }
}
@@ -170,7 +170,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollection({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -183,7 +183,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async reorderCollections({ orderedIds }: { orderedIds: number[] }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -198,7 +198,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async savePlaceToCollection(body: CollectionSavePlaceRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); }
}
@@ -219,7 +219,7 @@ export class CollectionsMcp {
{ collectionId, tripId, placeIds, force }: { collectionId: number; tripId: number; placeIds: number[]; force?: boolean },
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); }
}
@@ -232,7 +232,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollectionPlace({ placeId, ...body }: { placeId: number } & CollectionPlaceUpdateRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: await this.collections.updatePlace(ctx.userId, placeId, body) }); } catch (err) { return fail(err); }
}
@@ -245,7 +245,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async setCollectionPlaceStatus({ placeId, status }: { placeId: number; status: CollectionStatus }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); }
}
@@ -261,7 +261,7 @@ export class CollectionsMcp {
{ trip_id, place_ids, status }: CollectionSetStatusFromTripRequest,
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); }
}
@@ -277,7 +277,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async rateCollectionPlace({ placeId, rating }: { placeId: number; rating?: number | null }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); }
}
@@ -290,7 +290,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionPlace({ placeId }: { placeId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.deletePlace(ctx.userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -303,7 +303,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
}
@@ -318,7 +318,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async createCollectionLabel({ collection_id, name, color }: CollectionLabelCreateRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ label: this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); }
}
@@ -331,7 +331,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollectionLabel({ labelId, ...body }: { labelId: number } & CollectionLabelUpdateRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ label: this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); }
}
@@ -344,7 +344,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionLabel({ labelId }: { labelId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -360,7 +360,7 @@ export class CollectionsMcp {
{ label_ids, place_ids, remove }: CollectionLabelAssignRequest & { remove?: boolean },
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); }
}
@@ -375,7 +375,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async inviteToCollection({ collection_id, user_id, role }: CollectionInviteRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
// try/catch added with the post-fold quirk pass — the legacy handler was one
// of two where an unexpected throw escaped to the SDK instead of isError.
try {
@@ -402,7 +402,7 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId, role }: { collectionId: number; userId: number; role: CollectionRole },
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -418,7 +418,7 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId }: { collectionId: number; userId: number },
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -434,7 +434,7 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId }: { collectionId: number; userId: number },
ctx: McpContext,
) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -447,7 +447,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async acceptCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
// try/catch added with the post-fold quirk pass (see inviteToCollection).
try {
const res = this.collections.acceptInvite(ctx.userId, collectionId, undefined);
@@ -465,7 +465,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async declineCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -478,7 +478,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async leaveCollection({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
}
}
+3 -3
View File
@@ -67,7 +67,7 @@ export class DayNotesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.notes.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
@@ -98,7 +98,7 @@ export class DayNotesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = await this.notes.getNote(noteId, dayId, tripId);
@@ -120,7 +120,7 @@ export class DayNotesMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const note = await this.notes.getNote(noteId, dayId, tripId);
+5 -5
View File
@@ -62,7 +62,7 @@ export class DaysMcp {
{ tripId, dayId, ...fields }: { tripId: number; dayId: number } & DayUpdateRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const current = await this.days.getDay(dayId, tripId);
@@ -91,7 +91,7 @@ export class DaysMcp {
{ tripId, date, notes, position }: { tripId: number } & DayCreateRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (position === undefined) {
@@ -126,7 +126,7 @@ export class DaysMcp {
{ tripId, orderedIds }: { tripId: number } & DayReorderRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
try {
@@ -153,7 +153,7 @@ export class DaysMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
@@ -179,7 +179,7 @@ export class DaysMcp {
{ tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
+4 -4
View File
@@ -52,7 +52,7 @@ export class DocSyncMcp {
when: documentsAddonOn,
})
async getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok(await this.sync.status(tripId));
}
@@ -67,8 +67,8 @@ export class DocSyncMcp {
access: { group: 'files', mode: 'read' },
when: documentsAddonOn,
})
listIssues({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
async listIssues({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
if (links.length === 0) return ok({ configured: false, issues: [] });
return ok({ configured: true, issues: this.sync.issues(tripId) });
@@ -91,7 +91,7 @@ export class DocSyncMcp {
when: documentsAddonOn,
})
async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
if (links.length === 0) {
return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.');
+9 -7
View File
@@ -10,6 +10,7 @@ import { DatabaseService } from '../database/database.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { StorageService } from '../storage/storage.service';
import { UnitOfWork } from '../database/unit-of-work';
import { FilesService } from '../files/files.service';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -88,6 +89,7 @@ export class DocSyncService {
private readonly allowedTypes: AllowedFileTypesService,
private readonly realtime: RealtimeService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
) {}
// ── Entry points ───────────────────────────────────────────────────────────
@@ -332,7 +334,7 @@ export class DocSyncService {
case 'rename_local': {
this.db.connection.prepare('UPDATE trip_files SET original_name = ? WHERE id = ?').run(action.name, action.fileId);
this.realtime.broadcast(ctx.link.trip_id, 'file:updated', {
file: this.files.getFileById(action.fileId, ctx.link.trip_id),
file: await this.files.getFileById(action.fileId, ctx.link.trip_id),
});
this.db.connection
.prepare(`UPDATE document_sync_items SET remote_name = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`)
@@ -464,7 +466,7 @@ export class DocSyncService {
// Video is admitted the way the upload admits it, whatever the operator's
// list says: a clip the file manager takes must not come back as refused
// when it arrives through the store instead.
const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, this.allowedTypes.get());
const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, await this.allowedTypes.get());
if (isBlockedName(name) || !allowed) {
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
@@ -518,7 +520,7 @@ export class DocSyncService {
.prepare('SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id = ?')
.get(itemId) as { file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined);
const supersededId = pairing?.file_id ?? null;
const superseded = supersededId === null ? undefined : this.files.getFileById(supersededId, ctx.link.trip_id);
const superseded = supersededId === null ? undefined : await this.files.getFileById(supersededId, ctx.link.trip_id);
// The bytes TREK already holds, under a version marker that moved on
// metadata: Paperless bumps it for a tag or a correspondent as much as for
@@ -560,8 +562,8 @@ export class DocSyncService {
* a new revision is the same document to the trip, and without this every
* edit in the store quietly took the attachment off its booking.
*/
const { created, retired } = this.db.transaction(() => {
const file = this.files.createFile(
const { created, retired } = await this.uow.transactional(async () => {
const file = await this.files.createFile(
ctx.link.trip_id,
{ filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' },
// Attributed to the person whose connection brought it in, which is the
@@ -583,7 +585,7 @@ export class DocSyncService {
SELECT ?, reservation_id, assignment_id, place_id, budget_item_id FROM file_links WHERE file_id = ?`,
)
.run(file.id, supersededId);
this.files.softDeleteFile(supersededId as number);
await this.files.softDeleteFile(supersededId as number);
}
this.upsertItem(ctx.link, {
itemId,
@@ -624,7 +626,7 @@ export class DocSyncService {
return 'too_large';
}
const file = this.files.getFileById(local.fileId, ctx.link.trip_id);
const file = await this.files.getFileById(local.fileId, ctx.link.trip_id);
if (!file) return 'not_found';
// Re-read rather than trust the plan: a run walks a whole folder, and a
// document somebody deleted in the meantime would otherwise still be
@@ -16,7 +16,7 @@ export class AllowedFileTypesService {
constructor(private readonly db: DatabaseService) {}
/** Comma-separated, as the admin panel stores it. `*` means anything. */
get(): string {
async get(): Promise<string> {
try {
const row = this.db.get<{ value: string }>(
"SELECT value FROM app_settings WHERE key = 'allowed_file_types'",
@@ -31,17 +31,17 @@ export class FilesDownloadController {
@Param('tripId') tripId: string,
@Param('id') id: string,
): Promise<void> {
const auth = this.files.authenticateDownload(req);
const auth = await this.files.authenticateDownload(req);
if ('error' in auth) {
throw new HttpException({ error: auth.error }, auth.status);
}
const trip = this.files.verifyTripAccess(tripId, auth.userId);
const trip = await this.files.verifyTripAccess(tripId, auth.userId);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
+37 -30
View File
@@ -52,11 +52,18 @@ export function filesUploadFileFilter(allowedTypes: AllowedFileTypesService): Op
cb(err);
};
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
reject();
// R1.5: multer's fileFilter is a callback API that cannot await, and the
// allowed-extension list is now an async read. The decision runs in a
// detached async function that always answers through `cb` — multer holds
// the file stream until it does (make-middleware.js says as much). A
// rejection refuses the file, so the filter still fails closed.
void (async () => {
const allowed = (await allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
reject();
})().catch(() => reject());
};
}
@@ -94,16 +101,16 @@ export class FilesController {
// A file may only point at reservations/assignments/places/budget_items from its own trip.
// Reject cross-trip ids before they are stored — the reservation JOIN would
// otherwise leak the foreign reservation's title back to the caller.
private assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }) {
if (this.files.findForeignLinkTarget(tripId, body)) {
private async assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }) {
if (await this.files.findForeignLinkTarget(tripId, body)) {
throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400);
}
}
@UseGuards(TripAccessGuard)
@Get()
list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
return { files: this.files.listFiles(tripId, trash === 'true') };
async list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) {
return { files: await this.files.listFiles(tripId, trash === 'true') };
}
@Post()
@@ -120,7 +127,7 @@ export class FilesController {
// leaves up to the 500 MB video cap on disk (#823).
const cleanup = () => { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } };
try {
const trip = this.files.verifyTripAccess(tripId, user.id);
const trip = await this.files.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -148,7 +155,7 @@ export class FilesController {
throw new HttpException({ error: 'File is too large' }, 400);
}
try {
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
} catch (err) {
cleanup();
throw err;
@@ -161,7 +168,7 @@ export class FilesController {
cleanup();
throw err;
}
const created = this.files.createFile(tripId, file, user.id, {
const created = await this.files.createFile(tripId, file, user.id, {
place_id: body.place_id,
description: body.description,
reservation_id: body.reservation_id,
@@ -177,12 +184,12 @@ export class FilesController {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission to edit files' }, 403);
}
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const updated = this.files.updateFile(id, file, {
await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const updated = await this.files.updateFile(id, file, {
description: body.description,
place_id: body.place_id,
reservation_id: body.reservation_id,
@@ -198,11 +205,11 @@ export class FilesController {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
const updated = this.files.toggleStarred(id, file.starred);
const updated = await this.files.toggleStarred(id, file.starred);
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
return { file: updated };
}
@@ -223,7 +230,7 @@ export class FilesController {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getDeletedFile(id, tripId);
const file = await this.files.getDeletedFile(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found in trash' }, 404);
}
@@ -238,11 +245,11 @@ export class FilesController {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission to delete files' }, 403);
}
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.files.softDeleteFile(id);
await this.files.softDeleteFile(id);
this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId);
return { success: true };
}
@@ -254,11 +261,11 @@ export class FilesController {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getDeletedFile(id, tripId);
const file = await this.files.getDeletedFile(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found in trash' }, 404);
}
const restored = this.files.restoreFile(id);
const restored = await this.files.restoreFile(id);
this.files.broadcast(tripId, 'file:created', { file: restored }, socketId);
return { file: restored };
}
@@ -270,12 +277,12 @@ export class FilesController {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const links = await this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
return { success: true, links };
}
@@ -288,21 +295,21 @@ export class FilesController {
// deleteFileLink scopes by (linkId, fileId) only, so the file itself has to
// be resolved against :tripId first. Otherwise a member of any trip could
// drop a link row belonging to a foreign trip's file.
const file = this.files.getFileById(id, tripId);
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.files.deleteFileLink(linkId, id);
await this.files.deleteFileLink(linkId, id);
return { success: true };
}
@UseGuards(TripAccessGuard)
@Get(':id/links')
links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) {
const file = this.files.getFileById(id, tripId);
async links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) {
const file = await this.files.getFileById(id, tripId);
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
return { links: this.files.getFileLinks(id) };
return { links: await this.files.getFileLinks(id) };
}
}
+20 -20
View File
@@ -65,8 +65,8 @@ export class FilesMcp {
access: { group: 'files', mode: 'read' },
})
async listTripFiles({ tripId, trash }: { tripId: number; trash?: boolean }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
return ok({ files: this.files.listFiles(tripId, trash === true) });
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
return ok({ files: await this.files.listFiles(tripId, trash === true) });
}
@Tool({
@@ -83,7 +83,7 @@ export class FilesMcp {
access: { group: 'files', mode: 'content' },
})
async readTripFile({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
try {
const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId);
const asText = isTextual(mimetype);
@@ -121,15 +121,15 @@ export class FilesMcp {
{ tripId, fileId, ...fields }: { tripId: number; fileId: number } & FileUpdateRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
const current = this.files.getFileById(fileId, tripId);
const current = await this.files.getFileById(fileId, tripId);
if (!current) return errorResult('File not found.');
// Same enforcement as the REST route's assertLinkTargets, through the same
// service method: a foreign reservation id would otherwise come back with its
// title attached the next time this file is listed.
const foreign = this.files.findForeignLinkTarget(tripId, {
const foreign = await this.files.findForeignLinkTarget(tripId, {
reservation_id: fields.reservation_id,
place_id: fields.place_id,
budget_item_id: fields.budget_item_id,
@@ -138,7 +138,7 @@ export class FilesMcp {
// The rest spread carries only the keys the caller actually sent, which is what
// updateFile's presence sentinels need: naming the fields here would hand it an
// undefined description on a link-only call and wipe the description.
const file = this.files.updateFile(fileId, current, fields);
const file = await this.files.updateFile(fileId, current, fields);
this.guards.safeBroadcast(tripId, 'file:updated', { file });
return ok({ file });
}
@@ -161,19 +161,19 @@ export class FilesMcp {
{ tripId, fileId, ...targets }: { tripId: number; fileId: number } & FileLinkRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.');
// The REST body allows all three to be absent and stores a link row pointing at
// nothing. A tool caller that gets here with no target made a mistake, and saying
// so is more useful than a success that attached the file to nothing.
if (!targets.reservation_id && !targets.assignment_id && !targets.place_id && !targets.budget_item_id) {
return errorResult('Pass at least one of reservation_id, assignment_id, place_id or budget_item_id.');
}
const foreign = this.files.findForeignLinkTarget(tripId, targets);
const foreign = await this.files.findForeignLinkTarget(tripId, targets);
if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`);
const links = this.files.createFileLink(fileId, targets);
const links = await this.files.createFileLink(fileId, targets);
return ok({ success: true, links });
}
@@ -192,14 +192,14 @@ export class FilesMcp {
{ tripId, fileId, linkId }: { tripId: number; fileId: number; linkId: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
// deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved
// against :tripId first, exactly as the REST route does it. Otherwise a member of
// any trip could drop a link row belonging to a foreign trip's file.
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
this.files.deleteFileLink(linkId, fileId);
if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.');
await this.files.deleteFileLink(linkId, fileId);
return ok({ success: true });
}
@@ -214,8 +214,8 @@ export class FilesMcp {
access: { group: 'files', mode: 'read' },
})
async listTripFileLinks({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
return ok({ links: this.files.getFileLinks(fileId) });
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.');
return ok({ links: await this.files.getFileLinks(fileId) });
}
}
+12 -12
View File
@@ -54,9 +54,9 @@ export class FilesRpc {
) {}
@PluginMethod('files.list', { permission: 'db:read:files' })
list(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async list(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// Trash excluded, the same view the files tab shows.
return this.guards.tripRead(params, ctx, () => this.files.listFiles(num(params.tripId, 'tripId'), false));
return await this.guards.tripRead(params, ctx, async () => this.files.listFiles(num(params.tripId, 'tripId'), false));
}
@PluginMethod('files.getContent', { permission: 'db:read:files:content' })
@@ -121,7 +121,7 @@ export class FilesRpc {
const buf = Buffer.from(input.content_base64, 'base64');
if (buf.length === 0) throw new BadParams('file content is empty');
if (buf.length > CONTENT_MAX) throw new BadParams('file exceeds the 10MB plugin upload cap');
const foreign = this.files.findForeignLinkTarget(tripId, {
const foreign = await this.files.findForeignLinkTarget(tripId, {
reservation_id: input.reservation_id ?? null,
place_id: input.place_id ?? null,
});
@@ -133,7 +133,7 @@ export class FilesRpc {
await this.storage.put('files', filename, Readable.from(buf), {
contentType: input.mimetype || 'application/octet-stream',
});
const file = this.files.createFile(
const file = await this.files.createFile(
tripId,
{ filename, originalname: original, size: buf.length, mimetype: input.mimetype || 'application/octet-stream' },
actingUserId,
@@ -153,12 +153,12 @@ export class FilesRpc {
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file link');
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
if (!(await this.files.getFileById(fileId, tripId))) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const opts = asPayload(params.opts) as { reservation_id?: number; assignment_id?: number; place_id?: number };
// A link target on another trip would otherwise attach this trip's file to it.
const foreign = this.files.findForeignLinkTarget(tripId, opts);
const foreign = await this.files.findForeignLinkTarget(tripId, opts);
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
return this.files.createFileLink(fileId, {
return await this.files.createFileLink(fileId, {
reservation_id: opts.reservation_id != null ? String(opts.reservation_id) : null,
assignment_id: opts.assignment_id != null ? String(opts.assignment_id) : null,
place_id: opts.place_id != null ? String(opts.place_id) : null,
@@ -171,15 +171,15 @@ export class FilesRpc {
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
const current = this.files.getFileById(fileId, tripId);
const current = await this.files.getFileById(fileId, tripId);
if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const input = asPayload(params.input) as { description?: string; place_id?: number | null; reservation_id?: number | null };
const foreign = this.files.findForeignLinkTarget(tripId, {
const foreign = await this.files.findForeignLinkTarget(tripId, {
reservation_id: input.reservation_id ?? null,
place_id: input.place_id ?? null,
});
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
const file = this.files.updateFile(fileId, current, {
const file = await this.files.updateFile(fileId, current, {
description: input.description,
// null clears the link, undefined leaves it alone: the two are distinct here.
place_id: input.place_id != null ? String(input.place_id) : input.place_id === null ? null : undefined,
@@ -195,8 +195,8 @@ export class FilesRpc {
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
await this.guards.requireTripEdit(tripId, actor, DELETE_ACTION);
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
this.files.softDeleteFile(fileId);
if (!(await this.files.getFileById(fileId, tripId))) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
await this.files.softDeleteFile(fileId);
this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined);
return { deleted: true };
}
+17 -17
View File
@@ -81,7 +81,7 @@ export class FilesService {
private readonly storage: StorageService,
) {}
verifyTripAccess(tripId: string | number, userId: number) {
async verifyTripAccess(tripId: string | number, userId: number) {
return this.db.canAccessTrip(tripId, userId);
}
@@ -98,7 +98,7 @@ export class FilesService {
// download route)
// ---------------------------------------------------------------------------
authenticateDownload(req: Request): { userId: number } | { error: string; status: number } {
async authenticateDownload(req: Request): Promise<{ userId: number } | { error: string; status: number }> {
const cookieToken = (req as { cookies?: Record<string, string> }).cookies?.trek_session;
const authHeader = req.headers['authorization'];
const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined;
@@ -110,7 +110,7 @@ export class FilesService {
// Use the shared helper so the password_version gate applies here too;
// previously this bypassed the check and stolen download tokens stayed
// valid across a password reset.
const user = verifyJwtAndLoadUser(jwtToken);
const user = await verifyJwtAndLoadUser(jwtToken);
if (!user) return { error: 'Invalid or expired token', status: 401 };
return { userId: user.id };
}
@@ -136,10 +136,10 @@ export class FilesService {
* the first field that escapes `tripId`, or null when every supplied id belongs
* to the trip. Absent / null / zero ids are ignored (they clear the link).
*/
findForeignLinkTarget(
async findForeignLinkTarget(
tripId: string | number,
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }
): 'reservation_id' | 'assignment_id' | 'place_id' | 'budget_item_id' | null {
): Promise<'reservation_id' | 'assignment_id' | 'place_id' | 'budget_item_id' | null> {
if (opts.reservation_id && !this.db.get('SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?', opts.reservation_id, tripId)) {
return 'reservation_id';
}
@@ -159,11 +159,11 @@ export class FilesService {
// CRUD
// ---------------------------------------------------------------------------
getFileById(id: string | number, tripId: string | number): TripFile | undefined {
async getFileById(id: string | number, tripId: string | number): Promise<TripFile | undefined> {
return this.db.get<TripFile>('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?', id, tripId);
}
getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined {
async getDeletedFile(id: string | number, tripId: string | number): Promise<TripFile | undefined> {
return this.db.get<TripFile>('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL', id, tripId);
}
@@ -185,7 +185,7 @@ export class FilesService {
tripId: string | number,
fileId: string | number,
): Promise<{ name: string; mimetype: string; bytes: Buffer }> {
const file = this.getFileById(fileId, tripId);
const file = await this.getFileById(fileId, tripId);
if (!file || file.deleted_at) throw new FileContentError('not-found', `no file ${fileId} on trip ${tripId}`);
if ((file.file_size ?? 0) > FILE_CONTENT_MAX) {
throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`);
@@ -225,7 +225,7 @@ export class FilesService {
};
}
listFiles(tripId: string | number, showTrash: boolean) {
async listFiles(tripId: string | number, showTrash: boolean) {
const where = showTrash
? 'f.trip_id = ? AND f.deleted_at IS NOT NULL AND f.message_id IS NULL'
: 'f.trip_id = ? AND f.deleted_at IS NULL AND f.message_id IS NULL';
@@ -253,7 +253,7 @@ export class FilesService {
});
}
createFile(
async createFile(
tripId: string | number,
file: { filename: string; originalname: string; size: number; mimetype: string },
uploadedBy: number,
@@ -282,7 +282,7 @@ export class FilesService {
return formatFile(created);
}
updateFile(
async updateFile(
id: string | number,
current: TripFile,
updates: { description?: string; place_id?: string | number | null; reservation_id?: string | number | null; budget_item_id?: string | number | null }
@@ -312,7 +312,7 @@ export class FilesService {
return formatFile(updated);
}
toggleStarred(id: string | number, currentStarred: number | undefined) {
async toggleStarred(id: string | number, currentStarred: number | undefined) {
const newStarred = currentStarred ? 0 : 1;
this.db.run('UPDATE trip_files SET starred = ? WHERE id = ?', newStarred, id);
@@ -320,11 +320,11 @@ export class FilesService {
return formatFile(updated);
}
softDeleteFile(id: string | number) {
async softDeleteFile(id: string | number) {
this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', id);
}
restoreFile(id: string | number) {
async restoreFile(id: string | number) {
this.db.run('UPDATE trip_files SET deleted_at = NULL WHERE id = ?', id);
const restored = this.db.get<TripFile>(`${FILE_SELECT} WHERE f.id = ?`, id)!;
return formatFile(restored);
@@ -372,7 +372,7 @@ export class FilesService {
// Dedupe rides INSERT OR IGNORE + the UNIQUE(file_id, <target>) constraints;
// a genuine insert failure propagates to the global exception filter instead
// of returning a success-shaped links list (the legacy catch swallowed it).
createFileLink(
async createFileLink(
fileId: string | number,
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }
) {
@@ -382,11 +382,11 @@ export class FilesService {
return this.db.all('SELECT * FROM file_links WHERE file_id = ?', fileId);
}
deleteFileLink(linkId: string | number, fileId: string | number) {
async deleteFileLink(linkId: string | number, fileId: string | number) {
this.db.run('DELETE FROM file_links WHERE id = ? AND file_id = ?', linkId, fileId);
}
getFileLinks(fileId: string | number) {
async getFileLinks(fileId: string | number) {
return this.db.all(`
SELECT fl.*, r.title as reservation_title
FROM file_links fl
+8 -8
View File
@@ -104,11 +104,11 @@ export class DawarichMcp {
access: { group: 'places', mode: 'write' },
when: dawarichAddonOn,
})
acceptAsPlace(
async acceptAsPlace(
args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest }));
}
@@ -129,11 +129,11 @@ export class DawarichMcp {
access: { group: 'journey', mode: 'write' },
when: dawarichAddonOn,
})
acceptAsJournalEntry(
async acceptAsJournalEntry(
args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest }));
}
@@ -151,11 +151,11 @@ export class DawarichMcp {
access: { group: 'atlas', mode: 'write' },
when: dawarichAddonOn,
})
markBucketVisited(
async markBucketVisited(
{ suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
return this.run(() =>
this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }),
);
@@ -173,11 +173,11 @@ export class DawarichMcp {
access: { group: 'journey', mode: 'write' },
when: dawarichAddonOn,
})
dismiss(
async dismiss(
{ suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
if (!updated) return errorResult('Suggestion not found');
return ok({ suggestion: updated });
+1 -1
View File
@@ -158,7 +158,7 @@ export class JournalRpc {
}
// The operator's allow-list gates this path too, or the RPC would be the way
// around an admin setting that the REST upload obeys (journeyImageFileFilter).
const allowed = this.allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
const allowed = (await this.allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext.slice(1))) {
throw new BadParams(`file type ${ext} is not allowed`);
}
+15 -5
View File
@@ -78,13 +78,23 @@ export function journeyImageFileFilter(allowedTypes: AllowedFileTypesService): O
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
// R1.5: multer's fileFilter is a callback API that cannot await, and the
// allowed-extension list is now an async read. The decision runs in a
// detached async function that always answers through `cb`; a rejection
// refuses the file, so the filter still fails closed.
void (async () => {
const allowed = (await allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
})().catch(() => {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
cb(err);
});
};
}
+34 -34
View File
@@ -228,11 +228,11 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
createJourney(
async createJourney(
{ title, subtitle, trip_ids }: { title: string; subtitle?: string; trip_ids?: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
@@ -255,14 +255,14 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourney(
async updateJourney(
{ journeyId, ...data }: {
journeyId: number; title?: string; subtitle?: string; status?: string;
show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.updateJourney(journeyId, ctx.userId, data);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
@@ -278,8 +278,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
if (!result) return notFound('Journey not found or access denied.');
return ok(result);
@@ -293,8 +293,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -307,8 +307,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: this.journey.addTripToJourney(journeyId, tripId, ctx.userId) });
}
@@ -321,8 +321,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
@@ -352,7 +352,7 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
createJourneyEntry(
async createJourneyEntry(
{ journeyId, ...data }: {
journeyId: number; entry_date: string; title?: string; story?: string; entry_time?: string;
location_name?: string; location_lat?: number; location_lng?: number; mood?: string; weather?: string;
@@ -361,7 +361,7 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.createEntry(journeyId, ctx.userId, data);
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
@@ -395,7 +395,7 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyEntry(
async updateJourneyEntry(
{ entryId, ...data }: {
entryId: number; title?: string | null; story?: string | null; entry_date?: string;
entry_time?: string | null; location_name?: string | null; location_lat?: number | null;
@@ -406,7 +406,7 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.updateEntry(entryId, ctx.userId, data, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
@@ -422,8 +422,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteEntry(entryId, ctx.userId, undefined)) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
@@ -439,8 +439,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
@@ -458,11 +458,11 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
addJourneyContributor(
async addJourneyContributor(
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.addContributor(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -479,11 +479,11 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyContributorRole(
async updateJourneyContributorRole(
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -499,8 +499,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.removeContributor(journeyId, ctx.userId, targetUserId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -516,11 +516,11 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
updateJourneyPreferences(
async updateJourneyPreferences(
{ journeyId, hide_skeletons }: { journeyId: number; hide_skeletons?: boolean },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
@@ -543,14 +543,14 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
addJourneyProviderPhotos(
async addJourneyProviderPhotos(
{ journeyId, entryId, provider, asset_ids, media_types, caption, passphrase }: {
journeyId: number; entryId?: number; provider: z.infer<typeof PHOTO_PROVIDER>;
asset_ids: string[]; media_types?: Array<'image' | 'video'>; caption?: string; passphrase?: string;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// The REST routes derive the journey from the entry and let each per-asset
// add answer for itself, which in the array branch means a caller with no
// access gets an empty 200. Checking up front instead turns that silence
@@ -614,8 +614,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions);
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
@@ -629,8 +629,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.deleteJourneyShareLink(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -153,7 +153,7 @@ export class McpTransportService {
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = this.auth.verifyJwtToken(token);
const user = await this.auth.verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: false };
}
@@ -77,7 +77,7 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.notifications.markRead(notificationId, ctx.userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
@@ -93,7 +93,7 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.notifications.markUnread(notificationId, ctx.userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
@@ -107,7 +107,7 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markAllNotificationsRead(_input: Record<string, never>, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const count = await this.notifications.markAllRead(ctx.userId);
return ok({ success: true, count });
}
+3 -3
View File
@@ -41,7 +41,7 @@ export class OidcController {
@Get('login')
async login(@Req() req: Request, @Res() res: Response): Promise<void> {
if (!this.oidc.oidcLoginEnabled()) {
if (!(await this.oidc.oidcLoginEnabled())) {
res.status(403).json({ error: 'SSO login is disabled.' });
return;
}
@@ -107,7 +107,7 @@ export class OidcController {
const boundState = (req.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req));
if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled');
if (!(await this.oidc.oidcLoginEnabled())) return f('/login?oidc_error=sso_disabled');
if (oidcError) {
console.error('[OIDC] Provider error:', oidcError);
return f('/login?oidc_error=' + encodeURIComponent(oidcError));
@@ -252,7 +252,7 @@ export class AdminOidcController {
@ManagedForbidden('an instance-supplied issuer could assert any address as verified')
@Put()
async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
const result = this.oidc.updateOidcSettings(body);
const result = await this.oidc.updateOidcSettings(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
+5 -5
View File
@@ -250,7 +250,7 @@ export class OidcService implements OnModuleDestroy {
clearInterval(this.codeSweeper);
}
oidcLoginEnabled(): boolean { return this.auth.resolveAuthToggles().oidc_login; }
async oidcLoginEnabled(): Promise<boolean> { return (await this.auth.resolveAuthToggles()).oidc_login; }
getAppUrl() { return getAppUrl(); }
@@ -705,7 +705,7 @@ export class OidcService implements OnModuleDestroy {
}
if (!isFirstUser && !validInvite) {
const { oidc_registration } = this.auth.resolveAuthToggles();
const { oidc_registration } = await this.auth.resolveAuthToggles();
if (!oidc_registration) {
return { error: 'registration_disabled' };
}
@@ -792,15 +792,15 @@ export class OidcService implements OnModuleDestroy {
};
}
updateOidcSettings(data: {
async updateOidcSettings(data: {
issuer?: string;
client_id?: string;
client_secret?: string;
display_name?: string;
discovery_url?: string;
}): { error?: string; status?: number; success?: boolean } {
}): Promise<{ error?: string; status?: number; success?: boolean }> {
// Lockout prevention: can't remove OIDC config when password login is disabled
if ((data.issuer === '' || data.client_id === '') && !this.auth.resolveAuthToggles().password_login) {
if ((data.issuer === '' || data.client_id === '') && !(await this.auth.resolveAuthToggles()).password_login) {
return {
error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.',
status: 400,
+15 -15
View File
@@ -79,7 +79,7 @@ export class PackingMcp {
{ tripId, name, category, bag_id, quantity, weight_grams, checked, is_private, visibility, recipient_ids }: { tripId: number; name: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; checked?: boolean | number; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.createItem(tripId, {
@@ -116,7 +116,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
@@ -139,7 +139,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId);
@@ -174,7 +174,7 @@ export class PackingMcp {
{ tripId, itemId, name, category, bag_id, quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; is_private?: boolean },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
@@ -230,7 +230,7 @@ export class PackingMcp {
{ tripId, itemId, visibility, recipient_ids }: { tripId: number; itemId: number; visibility: PackingVisibility; recipient_ids?: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
@@ -258,7 +258,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.reorderItems(tripId, orderedIds);
@@ -296,7 +296,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
@@ -325,7 +325,7 @@ export class PackingMcp {
{ tripId, bagId, name, color, weight_limit_grams, user_id }: { tripId: number; bagId: number; name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
@@ -356,7 +356,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.deleteBag(tripId, bagId);
@@ -382,7 +382,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const members = this.packing.setBagMembers(tripId, bagId, userIds);
@@ -420,7 +420,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
@@ -440,7 +440,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const items = this.packing.applyTemplate(tripId, templateId);
@@ -477,7 +477,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
@@ -498,7 +498,7 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const result = this.packing.deletePackingTemplate(String(templateId));
@@ -528,7 +528,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 (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const created = this.packing.bulkImport(tripId, items, ctx.userId);
+9 -9
View File
@@ -96,7 +96,7 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.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, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
@@ -141,7 +141,7 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
@@ -204,7 +204,7 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const place = await 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, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
@@ -228,7 +228,7 @@ export class PlacesMcp {
{ tripId, placeId, rating }: { tripId: number; placeId: number; rating?: number | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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);
@@ -248,7 +248,7 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
// Scope the id to the trip before the hook: onPlaceDeleted keys on the place
@@ -342,7 +342,7 @@ export class PlacesMcp {
{ tripId, url, source, enrich }: { tripId: number; url: string; source: 'google-list' | 'naver-list'; enrich?: boolean },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
@@ -373,7 +373,7 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async importGpx(input: RoadtripGpxImport, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.');
@@ -424,7 +424,7 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
@@ -481,7 +481,7 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
+1 -1
View File
@@ -102,7 +102,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response):
}
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
const user = await verifyJwtAndLoadUser(rawToken);
if (user) return sendPhoto();
// Share-token path: require the token to cover the exact trip the
@@ -95,7 +95,7 @@ export class PluginsProxyController {
let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null;
if (route.auth) {
const token = extractToken(req);
const loaded = token ? verifyJwtAndLoadUser(token) : null;
const loaded = token ? await verifyJwtAndLoadUser(token) : null;
if (!loaded) {
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
return;
@@ -69,7 +69,7 @@ export class ReservationImportMcp {
{ tripId, flightIds, connections }: { tripId: number; flightIds: string[]; connections?: string[][] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -361,7 +361,7 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -437,7 +437,7 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.reservations.getReservation(reservationId, tripId);
@@ -468,7 +468,7 @@ export class ReservationsMcp {
access: { group: 'reservations', mode: 'write' },
})
async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted, accommodationDeleted } = await this.reservations.remove(reservationId, tripId);
@@ -495,7 +495,7 @@ export class ReservationsMcp {
{ tripId, reservationId, user_ids }: { tripId: number; reservationId: number; user_ids: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -533,7 +533,7 @@ export class ReservationsMcp {
{ tripId, positions, dayId }: { tripId: number; positions: { id: number; day_plan_position: number }[]; dayId?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -570,7 +570,7 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const current = this.reservations.getReservation(reservationId, tripId);
@@ -694,7 +694,7 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -802,7 +802,7 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -898,7 +898,7 @@ export class ReservationsMcp {
access: { group: 'reservations', mode: 'write' },
})
async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted } = await this.reservations.remove(reservationId, tripId);
@@ -42,7 +42,7 @@ export class DayBoundariesMcp {
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
})
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A stop from another trip is refused by the service, with the reason.
+2 -2
View File
@@ -25,8 +25,8 @@ export class GoogleRouteMcp {
// and create_and_assign_place are gated on. trips:write does not imply it, and
// a client that withheld places:write was getting an itinerary written anyway.
access: { group: 'places', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) })
import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
async import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
return answeringRefusals(async () => ok(await this.routes.import(input.tripId, ctx.userId, input)));
}
}
@@ -56,7 +56,7 @@ export class RoadtripPreferencesMcp {
when,
})
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A day window that ends before it starts is refused by the service, with the reason.
+5 -5
View File
@@ -74,7 +74,7 @@ export class RoadtripMcp {
{ tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
@@ -116,7 +116,7 @@ export class RoadtripMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
@@ -154,7 +154,7 @@ export class RoadtripMcp {
{ tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
@@ -176,7 +176,7 @@ export class RoadtripMcp {
when: roadtripAddonOn,
})
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
@@ -192,7 +192,7 @@ export class RoadtripMcp {
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn,
})
async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess();
+1 -1
View File
@@ -132,7 +132,7 @@ export class SettingsMcp {
access: { group: 'settings', mode: 'write' },
})
async updateDisplaySettings({ settings }: { settings: Record<string, unknown> }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entries = Object.entries(settings);
if (entries.length === 0) {
+2 -2
View File
@@ -66,7 +66,7 @@ export class ShareMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
@@ -87,7 +87,7 @@ export class ShareMcp {
access: (ctx) => canShareTrips(ctx.scopes),
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
this.share.remove(String(tripId));
+3 -3
View File
@@ -41,7 +41,7 @@ export class TagsMcp {
access: { group: 'places', mode: 'write' },
})
async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const tag = await 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 (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
const tag = await this.tags.update(tagId, name, color);
if (!(await 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 (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
await this.tags.remove(tagId);
return ok({ success: true });
+6 -6
View File
@@ -77,7 +77,7 @@ export class TodoMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
@@ -108,7 +108,7 @@ export class TodoMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Build bodyKeys to signal which nullable fields were explicitly provided
@@ -136,7 +136,7 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
@@ -157,7 +157,7 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.todos.deleteItem(tripId, itemId);
@@ -178,7 +178,7 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
await this.todos.reorderItems(tripId, orderedIds);
@@ -214,7 +214,7 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = await this.todos.updateCategoryAssignees(tripId, categoryName, userIds);
+1 -1
View File
@@ -183,7 +183,7 @@ export class TransitMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const day = await this.days.getDay(dayId, tripId);
@@ -256,7 +256,7 @@ export class TripMembersService {
// A guest is still a user id a plugin may hold data for, so erase that too — the
// host-side per-user tables + a durable own-db erasure per granted plugin — exactly
// like a full account deletion (otherwise a deleted guest's plugin data lingers).
this.userCleanup.erasePluginUserData(guestUserId);
await this.userCleanup.erasePluginUserData(guestUserId);
// Quirk fix on top of the 1:1 move: the budget re-split and the user delete
// run in one transaction, so a failure mid-flow can't leave the expense
// divisors re-derived for a guest that still exists (or vice versa). The
@@ -110,7 +110,7 @@ export class TripReadModelService {
todoItems: await this.todo.listItems(tripId),
budgetItems: await this.budget.listBudgetItems(tripId),
reservations: this.reservations.list(tripId),
files: this.files.listFiles(tripId, false),
files: await this.files.listFiles(tripId, false),
accommodations: this.accommodations.list(tripId),
members: [owner, ...(members || [])].filter(Boolean),
};
+10 -10
View File
@@ -104,7 +104,7 @@ export class TripsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
@@ -159,7 +159,7 @@ export class TripsMcp {
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!(await this.guards.hasTripPermission('trip_edit', tripId, ctx.userId))) return permissionDenied();
if (clear_dates && (start_date || end_date))
@@ -223,7 +223,7 @@ export class TripsMcp {
access: (ctx) => canDeleteTrips(ctx.scopes),
})
async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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 });
@@ -350,7 +350,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
@@ -379,7 +379,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
// Giving up your own access is not member management, so it carries no permission
// requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has.
@@ -400,7 +400,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async leaveTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
@@ -434,7 +434,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async createTripGuest({ tripId, name }: { tripId: number; name: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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)
@@ -462,7 +462,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async renameTripGuest({ tripId, guestId, name }: { tripId: number; guestId: number; name: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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)
@@ -488,7 +488,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteTripGuest({ tripId, guestId }: { tripId: number; guestId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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)
@@ -510,7 +510,7 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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);
+19 -19
View File
@@ -127,7 +127,7 @@ export class VacayMcp {
}: VacayUpdatePlanRequest,
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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.
@@ -149,7 +149,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async setVacayColor({ color }: { color: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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.
@@ -181,9 +181,9 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const me = this.auth.getCurrentUser(ctx.userId);
const me = await 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);
@@ -201,7 +201,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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 });
@@ -218,7 +218,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.declineInvite(ctx.userId, planId, undefined);
return ok({ success: true });
}
@@ -234,7 +234,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.cancelInvite(planId, targetUserId);
return ok({ success: true });
@@ -249,7 +249,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async dissolveVacayPlan(_args: Record<string, never>, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.dissolvePlan(ctx.userId, undefined);
return ok({ success: true });
}
@@ -279,7 +279,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async addVacayYear({ year }: { year: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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 });
@@ -296,7 +296,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async deleteVacayYear({ year }: { year: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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 });
@@ -335,7 +335,7 @@ export class VacayMcp {
{ date, fraction, kind, targetUserId }: { date: string; fraction?: 0.5 | 1; kind?: 'vacation' | 'comp'; targetUserId?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
let userId = ctx.userId;
if (targetUserId !== undefined && targetUserId !== ctx.userId) {
@@ -363,7 +363,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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);
@@ -397,7 +397,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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 });
@@ -421,7 +421,7 @@ export class VacayMcp {
{ region, type, label, color, sortOrder }: { region: string; type?: 'public_holiday' | 'school_holiday'; label?: string | null; color?: string; sortOrder?: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
// An omitted type stays undefined so the service default (and the default
// colour that goes with it) applies, exactly as on the REST route.
@@ -445,7 +445,7 @@ export class VacayMcp {
{ calendarId, label, color }: { calendarId: number; label?: string | null; color?: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await 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.');
@@ -463,7 +463,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
@@ -573,8 +573,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const me = this.auth.getCurrentUser(ctx.userId);
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const me = await 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);
@@ -592,7 +592,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) {
return errorResult('Share not found.');
}