mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
refactor(auth, files): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user