mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +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.');
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('AdminController read-only getters', () => {
|
||||
expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
|
||||
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
||||
await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
||||
expect(adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
||||
expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
||||
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
|
||||
@@ -106,7 +106,7 @@ beforeAll(async () => {
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
new AddonsService(dbs),
|
||||
new PasskeyService(dbs, auth, webauthn),
|
||||
new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)),
|
||||
auth,
|
||||
permissions,
|
||||
await makeNotificationsService(dbs, realtime),
|
||||
|
||||
@@ -84,7 +84,7 @@ beforeAll(async () => {
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
new AddonsService(dbs),
|
||||
new PasskeyService(dbs, auth, webauthn),
|
||||
new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)),
|
||||
auth,
|
||||
permissions,
|
||||
await makeNotificationsService(dbs, realtime),
|
||||
|
||||
@@ -66,28 +66,28 @@ beforeEach(() => vi.clearAllMocks());
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', async () => {
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
|
||||
expect(await thrownAsync(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid/expired token (verify returns null)', () => {
|
||||
it('rejects an invalid/expired token (verify returns null)', async () => {
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
||||
expect(await thrownAsync(() => guard.canActivate(context({ headers: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the loaded user and allows a valid token through', () => {
|
||||
it('attaches the loaded user and allows a valid token through', async () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user);
|
||||
expect(await guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
@@ -95,30 +95,30 @@ describe('JwtAuthGuard', () => {
|
||||
describe('CookieAuthGuard', () => {
|
||||
const guard = new CookieAuthGuard();
|
||||
|
||||
it('401s when the trek_session cookie is missing', () => {
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({
|
||||
it('401s when the trek_session cookie is missing', async () => {
|
||||
expect(await thrownAsync(() => guard.canActivate(context({ cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
// and when there is no cookies object at all
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({
|
||||
expect(await thrownAsync(() => guard.canActivate(context({})))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('401s when the cookie token fails verification', () => {
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
|
||||
it('401s when the cookie token fails verification', async () => {
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
||||
expect(await thrownAsync(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the user and allows a valid cookie session through', () => {
|
||||
it('attaches the user and allows a valid cookie session through', async () => {
|
||||
const req: Record<string, unknown> = { cookies: { trek_session: 'tok' } };
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user);
|
||||
expect(await guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
@@ -126,27 +126,27 @@ describe('CookieAuthGuard', () => {
|
||||
describe('OptionalJwtGuard', () => {
|
||||
const guard = new OptionalJwtGuard();
|
||||
|
||||
it('always allows; sets req.user to null when no token', () => {
|
||||
it('always allows; sets req.user to null when no token', async () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(await guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets req.user to null when a token verifies to nothing', () => {
|
||||
it('sets req.user to null when a token verifies to nothing', async () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
||||
expect(await guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
});
|
||||
|
||||
it('populates req.user from a valid token', () => {
|
||||
it('populates req.user from a valid token', async () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user);
|
||||
expect(await guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
@@ -170,14 +170,14 @@ describe('PasskeyEnabledGuard', () => {
|
||||
const resolveAuthToggles = vi.fn();
|
||||
const guard = new PasskeyEnabledGuard({ resolveAuthToggles } as unknown as AuthService);
|
||||
|
||||
it('404s when passkey_login is off', () => {
|
||||
resolveAuthToggles.mockReturnValue({ passkey_login: false });
|
||||
expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
|
||||
it('404s when passkey_login is off', async () => {
|
||||
resolveAuthToggles.mockResolvedValue({ passkey_login: false });
|
||||
expect(await thrownAsync(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
|
||||
});
|
||||
|
||||
it('allows when passkey_login is on', () => {
|
||||
resolveAuthToggles.mockReturnValue({ passkey_login: true });
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
it('allows when passkey_login is on', async () => {
|
||||
resolveAuthToggles.mockResolvedValue({ passkey_login: true });
|
||||
expect(await guard.canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,17 +241,17 @@ describe('PasskeyController', () => {
|
||||
}, 10000);
|
||||
|
||||
it('credentials: list, rename (error + success), delete (error + success)', async () => {
|
||||
passkey.listPasskeys.mockReturnValue([{ id: 'a' }]);
|
||||
expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||
passkey.listPasskeys.mockResolvedValue([{ id: 'a' }]);
|
||||
expect(await pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||
|
||||
passkey.renamePasskey.mockReturnValue({ error: 'Not found', status: 404 });
|
||||
expect(thrown(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
passkey.renamePasskey.mockReturnValue({ success: true });
|
||||
expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||
passkey.renamePasskey.mockResolvedValue({ error: 'Not found', status: 404 });
|
||||
expect(await thrownAsync(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
passkey.renamePasskey.mockResolvedValue({ success: true });
|
||||
expect(await pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||
|
||||
passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 });
|
||||
passkey.deletePasskey.mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
passkey.deletePasskey.mockReturnValue({ success: true });
|
||||
passkey.deletePasskey.mockResolvedValue({ success: true });
|
||||
expect(await pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' }));
|
||||
});
|
||||
|
||||
@@ -83,11 +83,11 @@ describe('RateLimitService', () => {
|
||||
});
|
||||
|
||||
describe('AuthPublicController', () => {
|
||||
it('demo-login maps error, else sets the cookie + returns token/user', () => {
|
||||
expect(thrown(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
||||
it('demo-login maps error, else sets the cookie + returns token/user', async () => {
|
||||
expect(await thrownAsync(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = apc(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user });
|
||||
expect(await c.demoLogin(req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
});
|
||||
|
||||
@@ -100,11 +100,11 @@ describe('AuthPublicController', () => {
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invite 429 when rate-limited', () => {
|
||||
it('invite 429 when rate-limited', async () => {
|
||||
const s = rl();
|
||||
s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet
|
||||
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial<AuthService>), s);
|
||||
expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
|
||||
expect(await c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
|
||||
});
|
||||
|
||||
it('login: mfa branch, success cookie, error mapping', async () => {
|
||||
@@ -142,9 +142,9 @@ describe('AuthPublicController', () => {
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('invite maps a service error', () => {
|
||||
it('invite maps a service error', async () => {
|
||||
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||
expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||
});
|
||||
|
||||
it('login takes the mfa-required branch and never sets a cookie', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('AuthPublicController', () => {
|
||||
for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(await thrownAsync(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
expect(await thrownAsync(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login sets cookie + audits; logout clears cookie', async () => {
|
||||
@@ -210,9 +210,9 @@ describe('AuthPublicController', () => {
|
||||
});
|
||||
|
||||
describe('AuthController (authenticated)', () => {
|
||||
it('GET /me 404 when missing, else returns the loaded user', () => {
|
||||
expect(thrown(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
||||
expect(ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
|
||||
it('GET /me 404 when missing, else returns the loaded user', async () => {
|
||||
expect(await thrownAsync(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
||||
expect(await ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
|
||||
});
|
||||
|
||||
it('change-password maps error, else audits', async () => {
|
||||
@@ -377,7 +377,7 @@ describe('AuthController (authenticated)', () => {
|
||||
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
|
||||
expect(await ac(asvc({}), rl(), {}, { deleteAvatar }).deleteAvatar(user)).toEqual({ removed: true });
|
||||
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] });
|
||||
expect(await ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] });
|
||||
expect(listUsers).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
@@ -388,8 +388,8 @@ describe('AuthController (authenticated)', () => {
|
||||
});
|
||||
|
||||
it('app-settings get maps error, else returns data; put maps error, else audits', async () => {
|
||||
expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
||||
|
||||
@@ -131,14 +131,14 @@ afterAll(() => testDb.close());
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => {
|
||||
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// OIDC users are created with a random bcrypt hash, so password_hash is set —
|
||||
// the old guard keyed off a missing hash and therefore let the reset through.
|
||||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||||
.run('sub-1129', 'https://idp.example', user.id);
|
||||
|
||||
const result = svc.requestPasswordReset(user.email, null);
|
||||
const result = await svc.requestPasswordReset(user.email, null);
|
||||
|
||||
expect(result.reason).toBe('oidc_only');
|
||||
expect(result.tokenForDelivery).toBeNull();
|
||||
@@ -147,9 +147,9 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => {
|
||||
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svc.requestPasswordReset(user.email, null);
|
||||
const result = await svc.requestPasswordReset(user.email, null);
|
||||
expect(result.reason).toBe('issued');
|
||||
expect(result.tokenForDelivery).toBeTruthy();
|
||||
});
|
||||
@@ -160,19 +160,19 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAppSettings', () => {
|
||||
it('AUTH-DB-013: returns 403 for non-admin', () => {
|
||||
it('AUTH-DB-013: returns 403 for non-admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svc.getAppSettings(user.id);
|
||||
const result = await svc.getAppSettings(user.id);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
|
||||
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
|
||||
.run();
|
||||
const result = svc.getAppSettings(user.id);
|
||||
const result = await svc.getAppSettings(user.id);
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data).toHaveProperty('allow_registration', 'true');
|
||||
@@ -184,25 +184,25 @@ describe('getAppSettings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isOidcOnlyMode', () => {
|
||||
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => {
|
||||
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
expect(svc.isOidcOnlyMode()).toBe(false);
|
||||
expect(await svc.isOidcOnlyMode()).toBe(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => {
|
||||
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', '');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', '');
|
||||
expect(svc.isOidcOnlyMode()).toBe(false);
|
||||
expect(await svc.isOidcOnlyMode()).toBe(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => {
|
||||
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
||||
expect(svc.isOidcOnlyMode()).toBe(true);
|
||||
expect(await svc.isOidcOnlyMode()).toBe(true);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -217,63 +217,63 @@ describe('resolveAuthToggles', () => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run();
|
||||
});
|
||||
|
||||
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => {
|
||||
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(true);
|
||||
expect(t.password_registration).toBe(true);
|
||||
expect(t.oidc_login).toBe(true);
|
||||
expect(t.oidc_registration).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', () => {
|
||||
it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(false);
|
||||
expect(t.password_registration).toBe(false);
|
||||
expect(t.oidc_login).toBe(true);
|
||||
expect(t.oidc_registration).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => {
|
||||
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(true);
|
||||
expect(t.password_registration).toBe(false);
|
||||
expect(t.oidc_login).toBe(true);
|
||||
expect(t.oidc_registration).toBe(false);
|
||||
});
|
||||
|
||||
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', () => {
|
||||
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
// New key present → use new keys, allow_registration ignored
|
||||
expect(t.password_registration).toBe(true);
|
||||
expect(t.oidc_registration).toBe(true); // defaults to true when key not set
|
||||
});
|
||||
|
||||
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', () => {
|
||||
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
// OIDC_ONLY forces password toggles off even when DB says true
|
||||
expect(t.password_login).toBe(false);
|
||||
expect(t.password_registration).toBe(false);
|
||||
});
|
||||
|
||||
it('AUTH-DB-022f: individual granular keys can be set independently', () => {
|
||||
it('AUTH-DB-022f: individual granular keys can be set independently', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run();
|
||||
const t = svc.resolveAuthToggles();
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(true);
|
||||
expect(t.password_registration).toBe(false);
|
||||
expect(t.oidc_login).toBe(true);
|
||||
@@ -286,26 +286,26 @@ describe('resolveAuthToggles', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setupMfa', () => {
|
||||
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => {
|
||||
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const result = svc.setupMfa(user.id, 'demo@nomad.app');
|
||||
const result = await svc.setupMfa(user.id, 'demo@nomad.app');
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/demo mode/i);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-023: returns 400 when MFA is already enabled', () => {
|
||||
it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
const result = svc.setupMfa(user.id, user.email);
|
||||
const result = await svc.setupMfa(user.id, user.email);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/already enabled/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => {
|
||||
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svc.setupMfa(user.id, user.email);
|
||||
const result = await svc.setupMfa(user.id, user.email);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(typeof result.secret).toBe('string');
|
||||
expect(result.secret!.length).toBeGreaterThan(0);
|
||||
@@ -320,17 +320,17 @@ describe('setupMfa', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('enableMfa', () => {
|
||||
it('AUTH-DB-025: returns 400 when no verification code is provided', () => {
|
||||
it('AUTH-DB-025: returns 400 when no verification code is provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svc.enableMfa(user.id, undefined);
|
||||
const result = await svc.enableMfa(user.id, undefined);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/code is required/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => {
|
||||
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// No setupMfa called first, so no pending entry exists
|
||||
const result = svc.enableMfa(user.id, '123456');
|
||||
const result = await svc.enableMfa(user.id, '123456');
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/no mfa setup in progress/i);
|
||||
});
|
||||
@@ -341,10 +341,10 @@ describe('enableMfa', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('disableMfa', () => {
|
||||
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => {
|
||||
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const result = svc.disableMfa(user.id, 'demo@nomad.app', {
|
||||
const result = await svc.disableMfa(user.id, 'demo@nomad.app', {
|
||||
password: 'password123',
|
||||
code: '000000',
|
||||
});
|
||||
@@ -353,22 +353,22 @@ describe('disableMfa', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-028: returns 400 when password or code is missing', () => {
|
||||
it('AUTH-DB-028: returns 400 when password or code is missing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const missingCode = svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined });
|
||||
const missingCode = await svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined });
|
||||
expect(missingCode.status).toBe(400);
|
||||
expect(missingCode.error).toMatch(/password and authenticator code/i);
|
||||
|
||||
const missingPassword = svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' });
|
||||
const missingPassword = await svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' });
|
||||
expect(missingPassword.status).toBe(400);
|
||||
expect(missingPassword.error).toMatch(/password and authenticator code/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => {
|
||||
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// mfa_enabled defaults to 0 / not set
|
||||
const result = svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
|
||||
const result = await svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/not enabled/i);
|
||||
});
|
||||
@@ -379,23 +379,23 @@ describe('disableMfa', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateInviteToken', () => {
|
||||
it('AUTH-DB-030: returns 404 for unknown token', () => {
|
||||
const result = svc.validateInviteToken('no-such-token');
|
||||
it('AUTH-DB-030: returns 404 for unknown token', async () => {
|
||||
const result = await svc.validateInviteToken('no-such-token');
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-DB-031: returns 410 when max_uses exceeded', () => {
|
||||
it('AUTH-DB-031: returns 410 when max_uses exceeded', async () => {
|
||||
// createInviteToken with used_count already at max
|
||||
const invite = createInviteToken(testDb, { max_uses: 1 });
|
||||
// manually set used_count = 1 to simulate exhaustion
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
const result = svc.validateInviteToken(invite.token);
|
||||
const result = await svc.validateInviteToken(invite.token);
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
|
||||
it('AUTH-DB-032: returns 410 when expired', () => {
|
||||
it('AUTH-DB-032: returns 410 when expired', async () => {
|
||||
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
|
||||
const result = svc.validateInviteToken(invite.token);
|
||||
const result = await svc.validateInviteToken(invite.token);
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
});
|
||||
@@ -430,13 +430,13 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('loginUser — OIDC-only mode', () => {
|
||||
it('AUTH-DB-035: returns 403 when oidc_only=true', () => {
|
||||
it('AUTH-DB-035: returns 403 when oidc_only=true', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
|
||||
const result = svc.loginUser({ email: user.email, password });
|
||||
const result = await svc.loginUser({ email: user.email, password });
|
||||
expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -446,13 +446,13 @@ describe('loginUser — OIDC-only mode', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('changePassword — OIDC-only mode', () => {
|
||||
it('AUTH-DB-036: returns 403 when oidc_only=true', () => {
|
||||
it('AUTH-DB-036: returns 403 when oidc_only=true', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
|
||||
const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -463,14 +463,14 @@ describe('changePassword — session invalidation', () => {
|
||||
const mcpCount = (id: number) =>
|
||||
(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c;
|
||||
|
||||
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => {
|
||||
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
tokens.createMcpToken(user.id, 'cli');
|
||||
|
||||
expect(pvOf(user.id)).toBe(0);
|
||||
expect(mcpCount(user.id)).toBe(1);
|
||||
|
||||
const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(typeof result.token).toBe('string'); // fresh session for the current device
|
||||
@@ -478,23 +478,23 @@ describe('changePassword — session invalidation', () => {
|
||||
expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked
|
||||
});
|
||||
|
||||
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => {
|
||||
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const stolen = svc.generateToken({ id: user.id }); // pv=0 at mint time
|
||||
const stolen = await svc.generateToken({ id: user.id }); // pv=0 at mint time
|
||||
|
||||
expect(verifyJwtAndLoadUser(stolen)).not.toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(stolen)).not.toBeNull();
|
||||
|
||||
svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump
|
||||
expect(await verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump
|
||||
});
|
||||
|
||||
it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', () => {
|
||||
it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true);
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true);
|
||||
expect(result.success).toBe(true);
|
||||
const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number };
|
||||
expect(decoded.remember).toBe(true);
|
||||
@@ -507,11 +507,11 @@ describe('changePassword — session invalidation', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('disableMfa — require_mfa policy', () => {
|
||||
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => {
|
||||
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
const result = svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
||||
const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/cannot be disabled/i);
|
||||
});
|
||||
@@ -522,25 +522,25 @@ describe('disableMfa — require_mfa policy', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verifyMfaLogin — validation', () => {
|
||||
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => {
|
||||
const result = svc.verifyMfaLogin({ mfa_token: undefined, code: undefined });
|
||||
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', async () => {
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: undefined, code: undefined });
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => {
|
||||
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||
const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.error).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => {
|
||||
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||
const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -670,18 +670,18 @@ describe('getAppConfig', () => {
|
||||
});
|
||||
|
||||
describe('demoLogin', () => {
|
||||
it('AUTH-DB-053: 404 outside demo mode', () => {
|
||||
it('AUTH-DB-053: 404 outside demo mode', async () => {
|
||||
vi.stubEnv('DEMO_MODE', '');
|
||||
expect(svc.demoLogin()).toEqual({ error: 'Not found', status: 404 });
|
||||
expect(await svc.demoLogin()).toEqual({ error: 'Not found', status: 404 });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', () => {
|
||||
it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
expect(svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 });
|
||||
expect(await svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 });
|
||||
// demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias).
|
||||
createUser(testDb, { email: 'demo@trek.app' });
|
||||
const result = svc.demoLogin();
|
||||
const result = await svc.demoLogin();
|
||||
expect(typeof result.token).toBe('string');
|
||||
expect(result.user).not.toHaveProperty('password_hash');
|
||||
vi.unstubAllEnvs();
|
||||
@@ -689,9 +689,9 @@ describe('demoLogin', () => {
|
||||
});
|
||||
|
||||
describe('validateInviteToken — valid path', () => {
|
||||
it('AUTH-DB-055: returns valid with usage metadata', () => {
|
||||
it('AUTH-DB-055: returns valid with usage metadata', async () => {
|
||||
const invite = createInviteToken(testDb, { max_uses: 5 });
|
||||
const result = svc.validateInviteToken(invite.token);
|
||||
const result = await svc.validateInviteToken(invite.token);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.max_uses).toBe(5);
|
||||
expect(result.used_count).toBe(0);
|
||||
@@ -730,26 +730,26 @@ describe('registerUser — success paths', () => {
|
||||
});
|
||||
|
||||
describe('loginUser — credential branches', () => {
|
||||
it('AUTH-DB-059: unknown email answers the generic 401', () => {
|
||||
const result = svc.loginUser({ email: 'nobody@x.com', password: 'pw' });
|
||||
it('AUTH-DB-059: unknown email answers the generic 401', async () => {
|
||||
const result = await svc.loginUser({ email: 'nobody@x.com', password: 'pw' });
|
||||
expect(result).toMatchObject({ error: 'Invalid email or password', status: 401, auditUserId: null });
|
||||
expect(result.auditDetails).toMatchObject({ reason: 'unknown_email' });
|
||||
});
|
||||
|
||||
it('AUTH-DB-060: missing fields answer the bespoke 400', () => {
|
||||
expect(svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 });
|
||||
it('AUTH-DB-060: missing fields answer the bespoke 400', async () => {
|
||||
expect(await svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', () => {
|
||||
it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svc.loginUser({ email: user.email, password: 'nope' });
|
||||
const result = await svc.loginUser({ email: user.email, password: 'nope' });
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.auditDetails).toMatchObject({ reason: 'wrong_password' });
|
||||
});
|
||||
|
||||
it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', () => {
|
||||
it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const result = svc.loginUser({ email: user.email, password });
|
||||
const result = await svc.loginUser({ email: user.email, password });
|
||||
expect(typeof result.token).toBe('string');
|
||||
expect(result.user).not.toHaveProperty('password_hash');
|
||||
expect(result.auditAction).toBe('user.login');
|
||||
@@ -758,24 +758,24 @@ describe('loginUser — credential branches', () => {
|
||||
expect(row.last_login).not.toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', () => {
|
||||
it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id);
|
||||
const { password } = { password: undefined };
|
||||
void password;
|
||||
const result = svc.loginUser({ email: user.email, password: 'TestPass' });
|
||||
const result = await svc.loginUser({ email: user.email, password: 'TestPass' });
|
||||
// wrong password still 401s before the MFA branch
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', () => {
|
||||
it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const loaded = svc.getCurrentUser(user.id);
|
||||
const loaded = await svc.getCurrentUser(user.id);
|
||||
expect(loaded?.id).toBe(user.id);
|
||||
expect(loaded).not.toHaveProperty('password_hash');
|
||||
expect(svc.getCurrentUser(999999)).toBeNull();
|
||||
expect(await svc.getCurrentUser(999999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -801,39 +801,39 @@ describe('deleteAccount', () => {
|
||||
});
|
||||
|
||||
describe('updateAppSettings', () => {
|
||||
it('AUTH-DB-071: 403 for non-admin', () => {
|
||||
it('AUTH-DB-071: 403 for non-admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(svc.updateAppSettings(user.id, {}).status).toBe(403);
|
||||
expect((await svc.updateAppSettings(user.id, {})).status).toBe(403);
|
||||
});
|
||||
|
||||
it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', () => {
|
||||
it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const result = svc.updateAppSettings(user.id, { require_mfa: true });
|
||||
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/secure your own account/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', () => {
|
||||
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
const result = svc.updateAppSettings(user.id, { require_mfa: true });
|
||||
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.auditSummary).toMatchObject({ require_mfa: true });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run();
|
||||
});
|
||||
|
||||
it('AUTH-DB-074: lockout prevention refuses disabling every login method', () => {
|
||||
it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const result = svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' });
|
||||
const result = await svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' });
|
||||
expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-075: the smtp_pass masking sentinel is skipped on a notification-settings change', () => {
|
||||
it('AUTH-DB-075: the smtp_pass masking sentinel is skipped on a notification-settings change', async () => {
|
||||
// The reminder crons read their gates per tick now, so a settings change no
|
||||
// longer flags (or needs) a scheduler restart.
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run();
|
||||
const result = svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' });
|
||||
const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' });
|
||||
expect(result.success).toBe(true);
|
||||
const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string };
|
||||
expect(value).toBe('stored'); // sentinel never overwrites the secret
|
||||
@@ -843,11 +843,11 @@ describe('updateAppSettings', () => {
|
||||
});
|
||||
|
||||
describe('MFA success flows', () => {
|
||||
it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', () => {
|
||||
it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const setup = svc.setupMfa(user.id, user.email);
|
||||
const setup = await svc.setupMfa(user.id, user.email);
|
||||
const code = authenticator.generate(setup.secret!);
|
||||
const result = svc.enableMfa(user.id, code);
|
||||
const result = await svc.enableMfa(user.id, code);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backup_codes).toHaveLength(10);
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string };
|
||||
@@ -855,129 +855,129 @@ describe('MFA success flows', () => {
|
||||
expect(row.mfa_secret).toBe('enc:' + setup.secret);
|
||||
});
|
||||
|
||||
it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', () => {
|
||||
it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
svc.setupMfa(user.id, user.email);
|
||||
expect(svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 });
|
||||
await svc.setupMfa(user.id, user.email);
|
||||
expect(await svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', () => {
|
||||
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
const result = svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) });
|
||||
const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) });
|
||||
expect(result).toEqual({ success: true, mfa_enabled: false });
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null };
|
||||
expect(row.mfa_enabled).toBe(0);
|
||||
expect(row.mfa_secret).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-DB-079: disableMfa answers 401 on a wrong password', () => {
|
||||
it('AUTH-DB-079: disableMfa answers 401 on a wrong password', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
expect(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))
|
||||
expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))
|
||||
.toEqual({ error: 'Incorrect password', status: 401 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', () => {
|
||||
it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
const interstitial = svc.loginUser({ email: user.email, password });
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
expect(interstitial.mfa_required).toBe(true);
|
||||
const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) });
|
||||
expect(typeof result.token).toBe('string');
|
||||
expect(result.auditUserId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', () => {
|
||||
it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
const codes = ['AAAA-1111', 'BBBB-2222'];
|
||||
// hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode.
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), user.id);
|
||||
const interstitial = svc.loginUser({ email: user.email, password });
|
||||
const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' });
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' });
|
||||
expect(typeof result.token).toBe('string');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string };
|
||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out
|
||||
// the spent code no longer verifies
|
||||
const again = svc.loginUser({ email: user.email, password });
|
||||
expect(svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' }).status).toBe(401);
|
||||
const again = await svc.loginUser({ email: user.email, password });
|
||||
expect((await svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' })).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', () => {
|
||||
it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const issued = svc.requestPasswordReset(user.email, '1.2.3.4');
|
||||
const issued = await svc.requestPasswordReset(user.email, '1.2.3.4');
|
||||
expect(issued.reason).toBe('issued');
|
||||
const result = svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' });
|
||||
const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' });
|
||||
expect(result).toEqual({ success: true, userId: user.id });
|
||||
const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number };
|
||||
expect(row.password_version).toBe(1);
|
||||
// token is burned — a second use answers the bespoke 400
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' }))
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' }))
|
||||
.toEqual({ error: 'This reset link has already been used', status: 400 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => {
|
||||
expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
|
||||
expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
|
||||
expect(svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' }))
|
||||
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => {
|
||||
expect(await svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
|
||||
expect(await svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
|
||||
expect(await svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' }))
|
||||
.toEqual({ error: 'Invalid or expired reset link', status: 400 });
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const issued = svc.requestPasswordReset(user.email, null);
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id);
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
||||
});
|
||||
|
||||
it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', () => {
|
||||
it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id);
|
||||
const issued = svc.requestPasswordReset(user.email, null);
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
// no code → mfa_required interstitial, token NOT burned
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ mfa_required: true, status: 200 });
|
||||
// wrong code → 401
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' }))
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' }))
|
||||
.toEqual({ error: 'Invalid MFA code', status: 401 });
|
||||
// backup code → success + code consumed
|
||||
expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' }))
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' }))
|
||||
.toEqual({ success: true, userId: user.id });
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string };
|
||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', () => {
|
||||
it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
||||
expect(svc.requestPasswordReset('whoever@x.com', null).reason).toBe('password_login_disabled');
|
||||
expect((await svc.requestPasswordReset('whoever@x.com', null)).reason).toBe('password_login_disabled');
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
svc.requestPasswordReset(user.email, null);
|
||||
svc.requestPasswordReset(user.email, null);
|
||||
svc.requestPasswordReset(user.email, null);
|
||||
expect(svc.requestPasswordReset(user.email, null).reason).toBe('throttled_per_email');
|
||||
await svc.requestPasswordReset(user.email, null);
|
||||
await svc.requestPasswordReset(user.email, null);
|
||||
await svc.requestPasswordReset(user.email, null);
|
||||
expect((await svc.requestPasswordReset(user.email, null)).reason).toBe('throttled_per_email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ephemeral + demo helpers', () => {
|
||||
it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', () => {
|
||||
it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', async () => {
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const { user: other } = createUser(testDb);
|
||||
expect(svc.isDemoUser(user.id)).toBe(false); // demo mode off
|
||||
expect(await svc.isDemoUser(user.id)).toBe(false); // demo mode off
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
expect(svc.isDemoUser(user.id)).toBe(true);
|
||||
expect(svc.isDemoUser(other.id)).toBe(false);
|
||||
expect(await svc.isDemoUser(user.id)).toBe(true);
|
||||
expect(await svc.isDemoUser(other.id)).toBe(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -1004,14 +1004,14 @@ describe('auth quirk fixes', () => {
|
||||
expect(used_count).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', () => {
|
||||
it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify([hashBackupCode('DDDD-4444')]), user.id);
|
||||
const interstitial = svc.loginUser({ email: user.email, password });
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
|
||||
const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' });
|
||||
|
||||
expect(typeof result.token).toBe('string');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes, login_count, last_login FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; login_count: number; last_login: string | null };
|
||||
@@ -1037,31 +1037,31 @@ describe('MCP token verification round-trips', () => {
|
||||
expect(tokens.verifyMcpToken('trek_no_such_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', () => {
|
||||
it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = svc.generateToken({ id: user.id });
|
||||
expect(svc.verifyJwtToken(token)?.id).toBe(user.id);
|
||||
expect(svc.verifyJwtToken('not-a-jwt')).toBeNull();
|
||||
const token = await svc.generateToken({ id: user.id });
|
||||
expect((await svc.verifyJwtToken(token))?.id).toBe(user.id);
|
||||
expect(await svc.verifyJwtToken('not-a-jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken remember claim (#1927)', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', () => {
|
||||
it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const long = jwt.decode(svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number };
|
||||
const long = jwt.decode(await svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number };
|
||||
expect(long.remember).toBe(true);
|
||||
expect(long.exp - long.iat).toBe(2592000);
|
||||
|
||||
const short = jwt.decode(svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number };
|
||||
const short = jwt.decode(await svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number };
|
||||
expect(short.remember).toBe(false);
|
||||
expect(short.exp - short.iat).toBe(86400);
|
||||
});
|
||||
|
||||
it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', () => {
|
||||
it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const decoded = jwt.decode(svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number };
|
||||
const decoded = jwt.decode(await svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number };
|
||||
expect('remember' in decoded).toBe(false);
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
@@ -71,65 +71,65 @@ describe('extractToken', () => {
|
||||
});
|
||||
|
||||
describe('verifyJwtAndLoadUser', () => {
|
||||
it('AUTH-JWT-001: returns the user for a valid token, without password_version', () => {
|
||||
it('AUTH-JWT-001: returns the user for a valid token, without password_version', async () => {
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 });
|
||||
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(token)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' });
|
||||
expect(await verifyJwtAndLoadUser(token)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' });
|
||||
});
|
||||
|
||||
it('AUTH-JWT-002: returns null for a malformed token', () => {
|
||||
expect(verifyJwtAndLoadUser('invalid.jwt.token')).toBeNull();
|
||||
it('AUTH-JWT-002: returns null for a malformed token', async () => {
|
||||
expect(await verifyJwtAndLoadUser('invalid.jwt.token')).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-003: returns null when the user no longer exists', () => {
|
||||
it('AUTH-JWT-003: returns null when the user no longer exists', async () => {
|
||||
userRow(undefined);
|
||||
expect(verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }))).toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-004: returns null for an expired token', () => {
|
||||
it('AUTH-JWT-004: returns null for an expired token', async () => {
|
||||
const expired = jwt.sign({ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 }, 'test-secret', { algorithm: 'HS256' });
|
||||
expect(verifyJwtAndLoadUser(expired)).toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(expired)).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-005: returns null for a token signed with the wrong secret', () => {
|
||||
expect(verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }))).toBeNull();
|
||||
it('AUTH-JWT-005: returns null for a token signed with the wrong secret', async () => {
|
||||
expect(await verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-006: rejects a purpose-scoped mfa_login token even when the user is valid', () => {
|
||||
it('AUTH-JWT-006: rejects a purpose-scoped mfa_login token even when the user is valid', async () => {
|
||||
// Issued after the password check but before TOTP, signed with the same
|
||||
// secret. It must never authenticate an ordinary request.
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 });
|
||||
const mfaToken = jwt.sign({ id: 1, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(mfaToken)).toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(mfaToken)).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-007: rejects a token whose password_version predates the user row', () => {
|
||||
it('AUTH-JWT-007: rejects a token whose password_version predates the user row', async () => {
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 });
|
||||
const stale = jwt.sign({ id: 1, pv: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(stale)).toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(stale)).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-008: accepts a token whose password_version matches', () => {
|
||||
it('AUTH-JWT-008: accepts a token whose password_version matches', async () => {
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 });
|
||||
const current = jwt.sign({ id: 1, pv: 2 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(current)).not.toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(current)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-009: a pre-pv token still works against a never-reset user (both read as 0)', () => {
|
||||
it('AUTH-JWT-009: a pre-pv token still works against a never-reset user (both read as 0)', async () => {
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: null });
|
||||
const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(legacy)).not.toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(legacy)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', () => {
|
||||
it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', async () => {
|
||||
userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 1 });
|
||||
const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
|
||||
expect(verifyJwtAndLoadUser(legacy)).toBeNull();
|
||||
expect(await verifyJwtAndLoadUser(legacy)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ interface Mocks {
|
||||
|
||||
/** The demo gate is off unless a case turns it on; every write tool asks it. */
|
||||
function makeMcp(m: Mocks = {}) {
|
||||
const auth: Partial<AuthService> = m.auth ?? { isDemoUser: () => false };
|
||||
const auth: Partial<AuthService> = m.auth ?? { isDemoUser: async () => false };
|
||||
return new DawarichMcp(
|
||||
(m.suggestions ?? {}) as DawarichSuggestionsService,
|
||||
(m.tracks ?? {}) as DawarichTracksService,
|
||||
@@ -221,7 +221,7 @@ describe('DawarichMcp surface', () => {
|
||||
// draws: reading is not a write. The gate is wired to answer "yes, demo"
|
||||
// here, so a check added to a read would turn both of these into refusals
|
||||
// and payload() would fail on the isError flag.
|
||||
const isDemoUser = vi.fn().mockReturnValue(true);
|
||||
const isDemoUser = vi.fn().mockResolvedValue(true);
|
||||
const listFn = vi.fn().mockReturnValue(list(1));
|
||||
const forTrip = vi.fn().mockResolvedValue({
|
||||
days: [], source: 'tracks', fetchedAt: '2026-05-02T09:00:00.000Z', pointCount: 0, truncated: false,
|
||||
@@ -431,30 +431,30 @@ describe('DawarichMcp list_dawarich_suggestions', () => {
|
||||
describe('DawarichMcp accept tools', () => {
|
||||
it.each(['acceptAsPlace', 'acceptAsJournalEntry', 'markBucketVisited', 'dismiss'])(
|
||||
'DAWARICH-MCP-040: %s refuses the demo account before the service is reached',
|
||||
(method) => {
|
||||
async (method) => {
|
||||
// One argument object for all four: the demo check runs before anything
|
||||
// reads the body, so the extra keys are exactly as irrelevant here as the
|
||||
// refusal makes them.
|
||||
const args = { suggestionId: 9, tripId: 2, journalId: 2 };
|
||||
const accept = vi.fn();
|
||||
const setState = vi.fn();
|
||||
const isDemoUser = vi.fn().mockReturnValue(true);
|
||||
const isDemoUser = vi.fn().mockResolvedValue(true);
|
||||
const mcp = makeMcp({ suggestions: { accept, setState }, auth: { isDemoUser } });
|
||||
|
||||
const call = (mcp as unknown as Record<string, (a: unknown, c: McpContext) => McpTextResult>)[method];
|
||||
expect(refusal(call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.');
|
||||
const call = (mcp as unknown as Record<string, (a: unknown, c: McpContext) => Promise<McpTextResult>>)[method];
|
||||
expect(refusal(await call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.');
|
||||
expect(isDemoUser).toHaveBeenCalledWith(7);
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('DAWARICH-MCP-041: accepting as a place forwards every correction under target "place", with the id kept out of the body', () => {
|
||||
it('DAWARICH-MCP-041: accepting as a place forwards every correction under target "place", with the id kept out of the body', async () => {
|
||||
// suggestionId is the second argument, not a body field. Leaving it in the
|
||||
// body would put a key into DawarichAccept that nothing reads, and the next
|
||||
// reader would reasonably assume it was the one being accepted.
|
||||
const accept = vi.fn().mockReturnValue({ createdPlaceId: 5, createdJournalEntryId: null, bucketListItemId: null });
|
||||
const out = payload(makeMcp({ suggestions: { accept } }).acceptAsPlace(
|
||||
const out = payload(await makeMcp({ suggestions: { accept } }).acceptAsPlace(
|
||||
{ suggestionId: 9, tripId: 2, dayId: 4, name: 'Hafen', notes: 'windy', lat: 53.5, lng: 9.9 },
|
||||
ctx,
|
||||
));
|
||||
@@ -472,15 +472,15 @@ describe('DawarichMcp accept tools', () => {
|
||||
expect(out).toMatchObject({ createdPlaceId: 5 });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-042: a bare accept sends only the target, so the service keeps its own defaults for the trip and the coordinates', () => {
|
||||
it('DAWARICH-MCP-042: a bare accept sends only the target, so the service keeps its own defaults for the trip and the coordinates', async () => {
|
||||
const accept = vi.fn().mockReturnValue({ createdPlaceId: 1 });
|
||||
makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx);
|
||||
await makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx);
|
||||
expect(accept).toHaveBeenCalledWith(7, 9, { target: 'place' });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-043: accepting as a journal entry forwards target "journal" with the journey, the story and the corrected stamp', () => {
|
||||
it('DAWARICH-MCP-043: accepting as a journal entry forwards target "journal" with the journey, the story and the corrected stamp', async () => {
|
||||
const accept = vi.fn().mockReturnValue({ createdJournalEntryId: 11 });
|
||||
const out = payload(makeMcp({ suggestions: { accept } }).acceptAsJournalEntry(
|
||||
const out = payload(await makeMcp({ suggestions: { accept } }).acceptAsJournalEntry(
|
||||
{ suggestionId: 9, journalId: 3, name: 'The harbour', notes: 'It rained.', date: '2026-05-01', time: '09:30' },
|
||||
ctx,
|
||||
));
|
||||
@@ -496,15 +496,15 @@ describe('DawarichMcp accept tools', () => {
|
||||
expect(out).toMatchObject({ createdJournalEntryId: 11 });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-044: ticking off a wish names it explicitly when the caller did', () => {
|
||||
it('DAWARICH-MCP-044: ticking off a wish names it explicitly when the caller did', async () => {
|
||||
const accept = vi.fn().mockReturnValue({ bucketListItemId: 8 });
|
||||
makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9, bucketListItemId: 8 }, ctx);
|
||||
await makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9, bucketListItemId: 8 }, ctx);
|
||||
expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: 8 });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-045: an omitted wish stays undefined rather than being guessed here, and the service falls back to the matched one', () => {
|
||||
it('DAWARICH-MCP-045: an omitted wish stays undefined rather than being guessed here, and the service falls back to the matched one', async () => {
|
||||
const accept = vi.fn().mockReturnValue({ bucketListItemId: 2 });
|
||||
makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9 }, ctx);
|
||||
await makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9 }, ctx);
|
||||
expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: undefined });
|
||||
});
|
||||
|
||||
@@ -515,9 +515,9 @@ describe('DawarichMcp accept tools', () => {
|
||||
['trip_required', 'A trip is required to create a place', 400],
|
||||
])(
|
||||
'DAWARICH-MCP-046: the AcceptError %s comes back as a refusal an assistant can read (%s), not as an exception and not as the HTTP %i the REST half answers',
|
||||
(code, message, status) => {
|
||||
async (code, message, status) => {
|
||||
const accept = vi.fn(() => { throw new AcceptError(code, message, status); });
|
||||
const result = makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx);
|
||||
const result = await makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx);
|
||||
|
||||
expect(refusal(result)).toBe(message);
|
||||
// The status code is the REST half's vocabulary. MCP has isError and a
|
||||
@@ -527,46 +527,46 @@ describe('DawarichMcp accept tools', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('DAWARICH-MCP-047: the same shaping covers the journal and bucket-list tools, because run() is shared and a second copy would drift', () => {
|
||||
it('DAWARICH-MCP-047: the same shaping covers the journal and bucket-list tools, because run() is shared and a second copy would drift', async () => {
|
||||
const accept = vi.fn(() => { throw new AcceptError('not_found', 'Bucket-list entry not found', 404); });
|
||||
const mcp = makeMcp({ suggestions: { accept } });
|
||||
|
||||
expect(refusal(mcp.acceptAsJournalEntry({ suggestionId: 9, journalId: 1 }, ctx))).toBe('Bucket-list entry not found');
|
||||
expect(refusal(mcp.markBucketVisited({ suggestionId: 9 }, ctx))).toBe('Bucket-list entry not found');
|
||||
expect(refusal(await mcp.acceptAsJournalEntry({ suggestionId: 9, journalId: 1 }, ctx))).toBe('Bucket-list entry not found');
|
||||
expect(refusal(await mcp.markBucketVisited({ suggestionId: 9 }, ctx))).toBe('Bucket-list entry not found');
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-048: anything that is not an AcceptError propagates, because a failed write must never be reported as a success', () => {
|
||||
it('DAWARICH-MCP-048: anything that is not an AcceptError propagates, because a failed write must never be reported as a success', async () => {
|
||||
// A locked database, or a permission lookup that blew up, is not a refusal
|
||||
// the caller can act on. Flattening it into a text result would hand the
|
||||
// assistant a message with no isError flag on it to notice.
|
||||
const accept = vi.fn(() => { throw new Error('SQLITE_BUSY: database is locked'); });
|
||||
expect(() => makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx))
|
||||
.toThrow('SQLITE_BUSY: database is locked');
|
||||
await expect(makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx))
|
||||
.rejects.toThrow('SQLITE_BUSY: database is locked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichMcp dismiss_dawarich_suggestion', () => {
|
||||
it('DAWARICH-MCP-050: dismissed is the default, because that is what the tool is for', () => {
|
||||
it('DAWARICH-MCP-050: dismissed is the default, because that is what the tool is for', async () => {
|
||||
const updated = { id: 4, state: 'dismissed' };
|
||||
const setState = vi.fn().mockReturnValue(updated);
|
||||
const out = payload(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4 }, ctx));
|
||||
const out = payload(await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4 }, ctx));
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(7, 4, 'dismissed');
|
||||
expect(out).toEqual({ suggestion: updated });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-051: "new" puts a dismissed stay back into the review list', () => {
|
||||
it('DAWARICH-MCP-051: "new" puts a dismissed stay back into the review list', async () => {
|
||||
const setState = vi.fn().mockReturnValue({ id: 4, state: 'new' });
|
||||
makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4, state: 'new' }, ctx);
|
||||
await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4, state: 'new' }, ctx);
|
||||
expect(setState).toHaveBeenCalledWith(7, 4, 'new');
|
||||
});
|
||||
|
||||
it("DAWARICH-MCP-052: somebody else's suggestion gets the answer a missing one gets: the service says null, the tool says not found", () => {
|
||||
it("DAWARICH-MCP-052: somebody else's suggestion gets the answer a missing one gets: the service says null, the tool says not found", async () => {
|
||||
// The service scopes its lookup by user_id, so null covers both cases and
|
||||
// the tool must not separate them. A different message for "exists but is
|
||||
// not yours" would let a caller enumerate other people's suggestion ids.
|
||||
const setState = vi.fn().mockReturnValue(null);
|
||||
expect(refusal(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 999 }, ctx)))
|
||||
expect(refusal(await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 999 }, ctx)))
|
||||
.toBe('Suggestion not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { RealtimeService } from '../../../../src/nest/realtime/realtime.ser
|
||||
import { createTables } from '../../../../src/db/schema';
|
||||
import { runMigrations } from '../../../../src/db/migrations';
|
||||
import { createTrip, createUser } from '../../../helpers/factories';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
const link = (id: number): LinkRow => ({ id, provider_id: 'paperless' } as LinkRow);
|
||||
|
||||
@@ -421,12 +422,9 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService;
|
||||
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
|
||||
const config = new DocSyncConfigService(dbs, registry);
|
||||
const service = new DocSyncService(
|
||||
dbs, config, registry,
|
||||
{} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs),
|
||||
{ broadcast: vi.fn() } as unknown as RealtimeService,
|
||||
addons,
|
||||
);
|
||||
// Built in beforeAll: DocSyncService now takes a UnitOfWork, and
|
||||
// createTestUnitOfWork is async — module-scope construction cannot await it.
|
||||
let service: DocSyncService;
|
||||
const registrar = { isEnabled: () => false } as unknown as CronRegistrarService;
|
||||
/** A job of its own per pass, so the interval never decides whether a tick runs. */
|
||||
const tick = () => new DocSyncJob(dbs, service, config, addons, registrar).tick();
|
||||
@@ -435,7 +433,14 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
|
||||
testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id);
|
||||
const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id);
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
service = new DocSyncService(
|
||||
dbs, config, registry,
|
||||
{} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs),
|
||||
{ broadcast: vi.fn() } as unknown as RealtimeService,
|
||||
addons,
|
||||
await createTestUnitOfWork(testDb),
|
||||
);
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
const ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync-job.test' }).user.id;
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('DocSyncMcp access', () => {
|
||||
it('does not reach the sync service at all for a trip the user cannot see', async () => {
|
||||
const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] });
|
||||
await mcp.syncNow({ tripId: 3 }, ctx);
|
||||
mcp.listIssues({ tripId: 3 }, ctx);
|
||||
await mcp.listIssues({ tripId: 3 }, ctx);
|
||||
await mcp.getTripDocumentSync({ tripId: 3 }, ctx);
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
expect(sync.status).not.toHaveBeenCalled();
|
||||
@@ -185,24 +185,24 @@ describe('get_trip_document_sync', () => {
|
||||
});
|
||||
|
||||
describe('list_trip_document_sync_issues', () => {
|
||||
it('says the trip is not configured rather than answering with an empty list', () => {
|
||||
it('says the trip is not configured rather than answering with an empty list', async () => {
|
||||
// An empty list and no connection at all read identically to an assistant,
|
||||
// and "everything is in step" is the wrong answer to give about documents
|
||||
// that were never being synced.
|
||||
const { mcp, sync } = makeMcp({ links: [] });
|
||||
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] });
|
||||
expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] });
|
||||
expect(sync.issues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an empty list for a configured trip with nothing to decide', () => {
|
||||
it('reports an empty list for a configured trip with nothing to decide', async () => {
|
||||
const { mcp } = makeMcp({ links: [link(1)], issues: [] });
|
||||
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] });
|
||||
expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] });
|
||||
});
|
||||
|
||||
it('passes the open issues through for a configured trip', () => {
|
||||
it('passes the open issues through for a configured trip', async () => {
|
||||
const issues = [{ id: 9, state: 'conflict', remote_name: 'boarding.pdf' }];
|
||||
const { mcp, sync } = makeMcp({ links: [link(1)], issues });
|
||||
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues });
|
||||
expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues });
|
||||
expect(sync.issues).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import type { FilesService } from '../../../../src/nest/files/files.service';
|
||||
import type { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -303,7 +304,7 @@ const switchProvider = (id: string, on: boolean) =>
|
||||
// ── Suite ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DocSyncService', () => {
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-'));
|
||||
@@ -330,6 +331,7 @@ describe('DocSyncService', () => {
|
||||
new AllowedFileTypesService(dbs),
|
||||
realtime,
|
||||
addons as unknown as AddonsService,
|
||||
await createTestUnitOfWork(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ const trip = { id: 5, user_id: 42 } as never;
|
||||
|
||||
// The 404 "Trip not found" cases moved to trip-access.guard.test.ts with the check.
|
||||
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
|
||||
it('GET / lists with the trash flag', () => {
|
||||
it('GET / lists with the trash flag', async () => {
|
||||
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(fc(fsvc({ listFiles } as Partial<FilesService>)).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
||||
expect(await fc(fsvc({ listFiles } as Partial<FilesService>)).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
||||
expect(listFiles).toHaveBeenCalledWith('5', true);
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
expect(await fc(fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), deleteFileLink } as Partial<FilesService>)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true });
|
||||
expect(deleteFileLink).toHaveBeenCalledWith('3', '9');
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
||||
expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
expect(await fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('the link routes resolve the file against :tripId, so a foreign file is 404', async () => {
|
||||
@@ -227,8 +227,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
expect(await rejected(fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(unlinkSvc.deleteFileLink).not.toHaveBeenCalled();
|
||||
const listSvc = foreign();
|
||||
// `links` is still a synchronous handler, so it throws rather than rejecting.
|
||||
expect(thrown(() => fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(listSvc.getFileLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
testDb.close();
|
||||
bareDb.close();
|
||||
});
|
||||
@@ -104,8 +104,8 @@ function seedTrip() {
|
||||
return { user, trip };
|
||||
}
|
||||
|
||||
function makeFile(tripId: number, userId: number, overrides: Partial<{ filename: string; originalname: string; size: number; mimetype: string }> = {}, opts: Parameters<FilesService['createFile']>[3] = {}) {
|
||||
return svc.createFile(
|
||||
async function makeFile(tripId: number, userId: number, overrides: Partial<{ filename: string; originalname: string; size: number; mimetype: string }> = {}, opts: Parameters<FilesService['createFile']>[3] = {}) {
|
||||
return await svc.createFile(
|
||||
tripId,
|
||||
{ filename: 'stored-name.pdf', originalname: 'visa.pdf', size: 1234, mimetype: 'application/pdf', ...overrides },
|
||||
userId,
|
||||
@@ -166,55 +166,55 @@ describe('files.constants', () => {
|
||||
// against the single remaining query owner.
|
||||
|
||||
describe('AllowedFileTypesService.get', () => {
|
||||
it('FILE-SVC-006: returns the app_settings value when set', () => {
|
||||
it('FILE-SVC-006: returns the app_settings value when set', async () => {
|
||||
setAppSetting(testDb, 'allowed_file_types', 'pdf,txt');
|
||||
expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe('pdf,txt');
|
||||
expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe('pdf,txt');
|
||||
});
|
||||
|
||||
it('FILE-SVC-007: returns the default when the row is absent', () => {
|
||||
expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
it('FILE-SVC-007: returns the default when the row is absent', async () => {
|
||||
expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
});
|
||||
|
||||
it('FILE-SVC-008: returns the default for an empty value (|| coercion, not ??)', () => {
|
||||
it('FILE-SVC-008: returns the default for an empty value (|| coercion, not ??)', async () => {
|
||||
setAppSetting(testDb, 'allowed_file_types', '');
|
||||
expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
});
|
||||
|
||||
it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', () => {
|
||||
expect(new AllowedFileTypesService(new DatabaseService(bareDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', async () => {
|
||||
expect(await new AllowedFileTypesService(new DatabaseService(bareDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
});
|
||||
});
|
||||
|
||||
// ── trip-scoped reads ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getFileById / getDeletedFile', () => {
|
||||
it('FILE-SVC-010: getFileById is trip-scoped', () => {
|
||||
it('FILE-SVC-010: getFileById is trip-scoped', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const other = createTrip(testDb, user.id);
|
||||
const file = makeFile(trip.id, user.id);
|
||||
expect(svc.getFileById(file.id, trip.id)?.id).toBe(file.id);
|
||||
expect(svc.getFileById(file.id, other.id)).toBeUndefined();
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
expect((await svc.getFileById(file.id, trip.id))?.id).toBe(file.id);
|
||||
expect(await svc.getFileById(file.id, other.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FILE-SVC-011: getDeletedFile returns only soft-deleted rows', () => {
|
||||
it('FILE-SVC-011: getDeletedFile returns only soft-deleted rows', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
expect(svc.getDeletedFile(file.id, trip.id)).toBeUndefined();
|
||||
svc.softDeleteFile(file.id);
|
||||
expect(svc.getDeletedFile(file.id, trip.id)?.id).toBe(file.id);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
expect(await svc.getDeletedFile(file.id, trip.id)).toBeUndefined();
|
||||
await svc.softDeleteFile(file.id);
|
||||
expect((await svc.getDeletedFile(file.id, trip.id))?.id).toBe(file.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFiles', () => {
|
||||
it('FILE-SVC-012: active list excludes trash, is starred-first and carries the formatted shape', () => {
|
||||
it('FILE-SVC-012: active list excludes trash, is starred-first and carries the formatted shape', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
makeFile(trip.id, user.id, { originalname: 'plain.pdf' });
|
||||
const starred = makeFile(trip.id, user.id, { originalname: 'starred.pdf' });
|
||||
const trashed = makeFile(trip.id, user.id, { originalname: 'gone.pdf' });
|
||||
svc.toggleStarred(starred.id, 0);
|
||||
svc.softDeleteFile(trashed.id);
|
||||
await makeFile(trip.id, user.id, { originalname: 'plain.pdf' });
|
||||
const starred = await makeFile(trip.id, user.id, { originalname: 'starred.pdf' });
|
||||
const trashed = await makeFile(trip.id, user.id, { originalname: 'gone.pdf' });
|
||||
await svc.toggleStarred(starred.id, 0);
|
||||
await svc.softDeleteFile(trashed.id);
|
||||
|
||||
const files = svc.listFiles(trip.id, false) as Record<string, unknown>[];
|
||||
const files = await svc.listFiles(trip.id, false) as Record<string, unknown>[];
|
||||
expect(files.map((f) => f.id)).toHaveLength(2);
|
||||
expect(files[0].id).toBe(starred.id); // ORDER BY f.starred DESC first
|
||||
expect(files.map((f) => f.id)).not.toContain(trashed.id);
|
||||
@@ -223,27 +223,27 @@ describe('listFiles', () => {
|
||||
expect(files[0]).toHaveProperty('uploaded_by_avatar');
|
||||
});
|
||||
|
||||
it('FILE-SVC-013: trash list returns only soft-deleted files', () => {
|
||||
it('FILE-SVC-013: trash list returns only soft-deleted files', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const kept = makeFile(trip.id, user.id);
|
||||
const trashed = makeFile(trip.id, user.id);
|
||||
svc.softDeleteFile(trashed.id);
|
||||
const kept = await makeFile(trip.id, user.id);
|
||||
const trashed = await makeFile(trip.id, user.id);
|
||||
await svc.softDeleteFile(trashed.id);
|
||||
|
||||
const trash = svc.listFiles(trip.id, true) as Record<string, unknown>[];
|
||||
const trash = await svc.listFiles(trip.id, true) as Record<string, unknown>[];
|
||||
expect(trash.map((f) => f.id)).toEqual([trashed.id]);
|
||||
expect(trash.map((f) => f.id)).not.toContain(kept.id);
|
||||
});
|
||||
|
||||
it('FILE-SVC-014: batches file_links and filters null targets by truthiness', () => {
|
||||
it('FILE-SVC-014: batches file_links and filters null targets by truthiness', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const linked = makeFile(trip.id, user.id);
|
||||
const bare = makeFile(trip.id, user.id);
|
||||
svc.createFileLink(linked.id, { reservation_id: reservation.id });
|
||||
svc.createFileLink(linked.id, { place_id: place.id });
|
||||
const linked = await makeFile(trip.id, user.id);
|
||||
const bare = await makeFile(trip.id, user.id);
|
||||
await svc.createFileLink(linked.id, { reservation_id: reservation.id });
|
||||
await svc.createFileLink(linked.id, { place_id: place.id });
|
||||
|
||||
const files = svc.listFiles(trip.id, false) as Record<string, unknown>[];
|
||||
const files = await svc.listFiles(trip.id, false) as Record<string, unknown>[];
|
||||
const linkedRow = files.find((f) => f.id === linked.id);
|
||||
const bareRow = files.find((f) => f.id === bare.id);
|
||||
expect(linkedRow.linked_reservation_ids).toEqual([reservation.id]);
|
||||
@@ -252,14 +252,14 @@ describe('listFiles', () => {
|
||||
expect(bareRow.linked_place_ids).toEqual([]);
|
||||
|
||||
const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid);
|
||||
svc.createFileLink(linked.id, { budget_item_id: item });
|
||||
const withReceipt = (svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === linked.id);
|
||||
await svc.createFileLink(linked.id, { budget_item_id: item });
|
||||
const withReceipt = (await svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === linked.id);
|
||||
expect(withReceipt.linked_budget_item_ids).toEqual([item]);
|
||||
expect((svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]);
|
||||
expect((await svc.listFiles(trip.id, false) as Record<string, unknown>[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]);
|
||||
|
||||
// The empty-trip guard skips the IN () batch entirely.
|
||||
const empty = createTrip(testDb, user.id);
|
||||
expect(svc.listFiles(empty.id, false)).toEqual([]);
|
||||
expect(await svc.listFiles(empty.id, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,41 +270,41 @@ describe('budget receipts', () => {
|
||||
return Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(tripId, 'Dinner').lastInsertRowid);
|
||||
}
|
||||
|
||||
it('FILE-SVC-040: an upload naming an expense gets its link row straight away', () => {
|
||||
it('FILE-SVC-040: an upload naming an expense gets its link row straight away', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const item = seedItem(trip.id);
|
||||
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
|
||||
const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item });
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
|
||||
});
|
||||
|
||||
it('FILE-SVC-041: an upload without one writes no link at all', () => {
|
||||
it('FILE-SVC-041: an upload without one writes no link at all', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', () => {
|
||||
it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const item = seedItem(trip.id);
|
||||
const file = makeFile(trip.id, user.id);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
|
||||
svc.updateFile(file.id, file, { budget_item_id: item });
|
||||
await svc.updateFile(file.id, file, { budget_item_id: item });
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
|
||||
|
||||
// Sending it twice must not double the row.
|
||||
svc.updateFile(file.id, file, { budget_item_id: item });
|
||||
await svc.updateFile(file.id, file, { budget_item_id: item });
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 1 });
|
||||
|
||||
svc.updateFile(file.id, file, { budget_item_id: null });
|
||||
await svc.updateFile(file.id, file, { budget_item_id: null });
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL').get(file.id)).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('FILE-SVC-043: leaving budget_item_id out touches no link', () => {
|
||||
it('FILE-SVC-043: leaving budget_item_id out touches no link', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const item = seedItem(trip.id);
|
||||
const file = makeFile(trip.id, user.id, {}, { budget_item_id: item });
|
||||
const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item });
|
||||
|
||||
svc.updateFile(file.id, file, { description: 'renamed' });
|
||||
await svc.updateFile(file.id, file, { description: 'renamed' });
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 });
|
||||
});
|
||||
});
|
||||
@@ -312,9 +312,9 @@ describe('budget receipts', () => {
|
||||
// ── createFile / updateFile / toggleStarred ───────────────────────────────────
|
||||
|
||||
describe('createFile', () => {
|
||||
it('FILE-SVC-015: coerces falsy opts to NULL (|| null) and re-selects the formatted row', () => {
|
||||
it('FILE-SVC-015: coerces falsy opts to NULL (|| null) and re-selects the formatted row', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id, {}, { place_id: '', reservation_id: undefined, description: '' });
|
||||
const file = await makeFile(trip.id, user.id, {}, { place_id: '', reservation_id: undefined, description: '' });
|
||||
const row = testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(file.id) as Record<string, unknown>;
|
||||
expect(row.place_id).toBeNull();
|
||||
expect(row.reservation_id).toBeNull();
|
||||
@@ -323,10 +323,10 @@ describe('createFile', () => {
|
||||
expect((file as unknown as Record<string, unknown>).uploaded_by_name).toBe(user.username);
|
||||
});
|
||||
|
||||
it('FILE-SVC-016: stores the provided metadata and links the reservation title through FILE_SELECT', () => {
|
||||
it('FILE-SVC-016: stores the provided metadata and links the reservation title through FILE_SELECT', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Night train' });
|
||||
const file = makeFile(trip.id, user.id, { originalname: 'ticket.pdf', size: 99, mimetype: 'application/pdf' }, {
|
||||
const file = await makeFile(trip.id, user.id, { originalname: 'ticket.pdf', size: 99, mimetype: 'application/pdf' }, {
|
||||
reservation_id: String(reservation.id),
|
||||
description: 'the booking',
|
||||
});
|
||||
@@ -339,23 +339,23 @@ describe('createFile', () => {
|
||||
});
|
||||
|
||||
describe('updateFile', () => {
|
||||
it('FILE-SVC-017: undefined fields keep the current values', () => {
|
||||
it('FILE-SVC-017: undefined fields keep the current values', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const file = makeFile(trip.id, user.id, {}, { description: 'keep me', place_id: String(place.id) });
|
||||
const current = svc.getFileById(file.id, trip.id)!;
|
||||
const updated = svc.updateFile(file.id, current, {}) as Record<string, unknown>;
|
||||
const file = await makeFile(trip.id, user.id, {}, { description: 'keep me', place_id: String(place.id) });
|
||||
const current = await svc.getFileById(file.id, trip.id)!;
|
||||
const updated = await svc.updateFile(file.id, current, {}) as Record<string, unknown>;
|
||||
expect(updated.description).toBe('keep me');
|
||||
expect(updated.place_id).toBe(place.id);
|
||||
});
|
||||
|
||||
it("FILE-SVC-018: '' clears every field (|| null) — description matches createFile's coercion", () => {
|
||||
it("FILE-SVC-018: '' clears every field (|| null) — description matches createFile's coercion", async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = makeFile(trip.id, user.id, {}, { description: 'old', place_id: String(place.id), reservation_id: String(reservation.id) });
|
||||
const current = svc.getFileById(file.id, trip.id)!;
|
||||
const updated = svc.updateFile(file.id, current, { description: '', place_id: '', reservation_id: null }) as Record<string, unknown>;
|
||||
const file = await makeFile(trip.id, user.id, {}, { description: 'old', place_id: String(place.id), reservation_id: String(reservation.id) });
|
||||
const current = await svc.getFileById(file.id, trip.id)!;
|
||||
const updated = await svc.updateFile(file.id, current, { description: '', place_id: '', reservation_id: null }) as Record<string, unknown>;
|
||||
expect(updated.description).toBeNull(); // '' → NULL on update too (post-migration fix: symmetric with createFile)
|
||||
expect(updated.place_id).toBeNull();
|
||||
expect(updated.reservation_id).toBeNull();
|
||||
@@ -363,28 +363,28 @@ describe('updateFile', () => {
|
||||
});
|
||||
|
||||
describe('toggleStarred / softDeleteFile / restoreFile', () => {
|
||||
it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', () => {
|
||||
it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
const on = svc.toggleStarred(file.id, 0);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
const on = await svc.toggleStarred(file.id, 0);
|
||||
expect(on.starred).toBe(1);
|
||||
const off = svc.toggleStarred(file.id, on.starred);
|
||||
const off = await svc.toggleStarred(file.id, on.starred);
|
||||
expect(off.starred).toBe(0);
|
||||
});
|
||||
|
||||
it('FILE-SVC-020: softDeleteFile stamps deleted_at', () => {
|
||||
it('FILE-SVC-020: softDeleteFile stamps deleted_at', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
svc.softDeleteFile(file.id);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
await svc.softDeleteFile(file.id);
|
||||
const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as Record<string, unknown>;
|
||||
expect(row.deleted_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FILE-SVC-021: restoreFile clears deleted_at and returns the formatted row', () => {
|
||||
it('FILE-SVC-021: restoreFile clears deleted_at and returns the formatted row', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
svc.softDeleteFile(file.id);
|
||||
const restored = svc.restoreFile(file.id) as Record<string, unknown>;
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
await svc.softDeleteFile(file.id);
|
||||
const restored = await svc.restoreFile(file.id) as Record<string, unknown>;
|
||||
expect(restored.deleted_at).toBeNull();
|
||||
expect(restored.url).toBe(`/api/trips/${trip.id}/files/${file.id}/download`);
|
||||
});
|
||||
@@ -395,46 +395,46 @@ describe('toggleStarred / softDeleteFile / restoreFile', () => {
|
||||
describe('permanentDeleteFile', () => {
|
||||
it('FILE-SVC-022: deletes the storage object (idempotent on missing), then the DB row', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id, { filename: 'on-disk.pdf' });
|
||||
const file = await makeFile(trip.id, user.id, { filename: 'on-disk.pdf' });
|
||||
storageDelete.mockResolvedValue(undefined);
|
||||
await svc.permanentDeleteFile(svc.getFileById(file.id, trip.id) as TripFile);
|
||||
await svc.permanentDeleteFile(await svc.getFileById(file.id, trip.id) as TripFile);
|
||||
expect(storageDelete).toHaveBeenCalledWith('files', 'on-disk.pdf');
|
||||
expect(svc.getFileById(file.id, trip.id)).toBeUndefined();
|
||||
expect(await svc.getFileById(file.id, trip.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FILE-SVC-023: a delete failure logs [files], rethrows and keeps the DB row', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id, { filename: 'stuck.pdf' });
|
||||
const file = await makeFile(trip.id, user.id, { filename: 'stuck.pdf' });
|
||||
const boom = new Error('EACCES');
|
||||
storageDelete.mockRejectedValue(boom);
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(svc.permanentDeleteFile(svc.getFileById(file.id, trip.id) as TripFile)).rejects.toThrow('EACCES');
|
||||
await expect(svc.permanentDeleteFile(await svc.getFileById(file.id, trip.id) as TripFile)).rejects.toThrow('EACCES');
|
||||
expect(err).toHaveBeenCalledWith('[files] unlink failed for stuck.pdf, keeping DB row:', boom);
|
||||
expect(svc.getFileById(file.id, trip.id)).toBeDefined();
|
||||
expect(await svc.getFileById(file.id, trip.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emptyTrash', () => {
|
||||
it('FILE-SVC-024: unlinks every trashed file and deletes their rows, returning the count', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const a = makeFile(trip.id, user.id, { filename: 'a.pdf' });
|
||||
const b = makeFile(trip.id, user.id, { filename: 'b.pdf' });
|
||||
const kept = makeFile(trip.id, user.id, { filename: 'kept.pdf' });
|
||||
svc.softDeleteFile(a.id);
|
||||
svc.softDeleteFile(b.id);
|
||||
const a = await makeFile(trip.id, user.id, { filename: 'a.pdf' });
|
||||
const b = await makeFile(trip.id, user.id, { filename: 'b.pdf' });
|
||||
const kept = await makeFile(trip.id, user.id, { filename: 'kept.pdf' });
|
||||
await svc.softDeleteFile(a.id);
|
||||
await svc.softDeleteFile(b.id);
|
||||
storageDelete.mockResolvedValue(undefined);
|
||||
|
||||
await expect(svc.emptyTrash(trip.id)).resolves.toBe(2);
|
||||
expect(svc.listFiles(trip.id, true)).toEqual([]);
|
||||
expect(svc.getFileById(kept.id, trip.id)).toBeDefined();
|
||||
expect(await svc.listFiles(trip.id, true)).toEqual([]);
|
||||
expect(await svc.getFileById(kept.id, trip.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FILE-SVC-025: a partial unlink failure keeps the failed row, swallows the error and counts only successes', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const good = makeFile(trip.id, user.id, { filename: 'good.pdf' });
|
||||
const bad = makeFile(trip.id, user.id, { filename: 'bad.pdf' });
|
||||
svc.softDeleteFile(good.id);
|
||||
svc.softDeleteFile(bad.id);
|
||||
const good = await makeFile(trip.id, user.id, { filename: 'good.pdf' });
|
||||
const bad = await makeFile(trip.id, user.id, { filename: 'bad.pdf' });
|
||||
await svc.softDeleteFile(good.id);
|
||||
await svc.softDeleteFile(bad.id);
|
||||
const boom = new Error('EBUSY');
|
||||
storageDelete.mockImplementation((_category: string, name: string) =>
|
||||
name.includes('bad.pdf') ? Promise.reject(boom) : Promise.resolve()
|
||||
@@ -443,8 +443,8 @@ describe('emptyTrash', () => {
|
||||
|
||||
await expect(svc.emptyTrash(trip.id)).resolves.toBe(1);
|
||||
expect(err).toHaveBeenCalledWith('[files] unlink failed for bad.pdf, keeping DB row:', boom);
|
||||
expect(svc.getFileById(good.id, trip.id)).toBeUndefined();
|
||||
expect(svc.getDeletedFile(bad.id, trip.id)).toBeDefined();
|
||||
expect(await svc.getFileById(good.id, trip.id)).toBeUndefined();
|
||||
expect(await svc.getDeletedFile(bad.id, trip.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('FILE-SVC-026: an empty trash resolves to 0 without touching storage', async () => {
|
||||
@@ -464,7 +464,7 @@ describe('findForeignLinkTarget', () => {
|
||||
return { user, mine, foreign };
|
||||
}
|
||||
|
||||
it('FILE-SVC-027: flags each foreign target kind and passes same-trip ids', () => {
|
||||
it('FILE-SVC-027: flags each foreign target kind and passes same-trip ids', async () => {
|
||||
const { mine, foreign } = twoTrips();
|
||||
const foreignRes = createReservation(testDb, foreign.id);
|
||||
const foreignPlace = createPlace(testDb, foreign.id);
|
||||
@@ -472,27 +472,27 @@ describe('findForeignLinkTarget', () => {
|
||||
const foreignAssignment = createDayAssignment(testDb, foreignDay.id, createPlace(testDb, foreign.id).id);
|
||||
const myRes = createReservation(testDb, mine.id);
|
||||
|
||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id })).toBe('reservation_id');
|
||||
expect(svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id');
|
||||
expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
|
||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull();
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id })).toBe('reservation_id');
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id');
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id');
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull();
|
||||
|
||||
// A receipt may only point at an expense on the same trip.
|
||||
const foreignItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(foreign.id, 'Foreign').lastInsertRowid);
|
||||
const myItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(mine.id, 'Mine').lastInsertRowid);
|
||||
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id');
|
||||
expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull();
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id');
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull();
|
||||
});
|
||||
|
||||
it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', () => {
|
||||
it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', async () => {
|
||||
const { mine, foreign } = twoTrips();
|
||||
const foreignRes = createReservation(testDb, foreign.id);
|
||||
const foreignPlace = createPlace(testDb, foreign.id);
|
||||
|
||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: 0, place_id: null, assignment_id: undefined })).toBeNull();
|
||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: '' })).toBeNull();
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: 0, place_id: null, assignment_id: undefined })).toBeNull();
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: '' })).toBeNull();
|
||||
// Both foreign — the reservation check runs before the place check.
|
||||
expect(svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id, place_id: foreignPlace.id })).toBe('reservation_id');
|
||||
expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id, place_id: foreignPlace.id })).toBe('reservation_id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,89 +511,89 @@ type FileLinkRow = {
|
||||
};
|
||||
|
||||
describe('createFileLink / deleteFileLink / getFileLinks', () => {
|
||||
it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', () => {
|
||||
it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Ferry' });
|
||||
const file = makeFile(trip.id, user.id);
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
|
||||
const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as FileLinkRow[];
|
||||
const links = await svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as FileLinkRow[];
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].reservation_id).toBe(reservation.id);
|
||||
expect(links[0].place_id).toBeNull();
|
||||
|
||||
const again = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[];
|
||||
const again = await svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[];
|
||||
expect(again).toHaveLength(1); // UNIQUE(file_id, reservation_id) + OR IGNORE
|
||||
|
||||
const hydrated = svc.getFileLinks(file.id) as FileLinkRow[];
|
||||
const hydrated = await svc.getFileLinks(file.id) as FileLinkRow[];
|
||||
expect(hydrated[0].reservation_title).toBe('Ferry');
|
||||
});
|
||||
|
||||
it('FILE-SVC-030: an insert error propagates (post-migration fix: no silent swallow)', () => {
|
||||
it('FILE-SVC-030: an insert error propagates (post-migration fix: no silent swallow)', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const file = makeFile(trip.id, user.id);
|
||||
expect(() => svc.createFileLink(file.id, { reservation_id: { bad: true } as never })).toThrow();
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
await expect(svc.createFileLink(file.id, { reservation_id: { bad: true } as never })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('FILE-SVC-031: deleteFileLink is scoped to (id AND file_id)', () => {
|
||||
it('FILE-SVC-031: deleteFileLink is scoped to (id AND file_id)', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = makeFile(trip.id, user.id);
|
||||
const other = makeFile(trip.id, user.id);
|
||||
const [link] = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[];
|
||||
const file = await makeFile(trip.id, user.id);
|
||||
const other = await makeFile(trip.id, user.id);
|
||||
const [link] = await svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[];
|
||||
|
||||
svc.deleteFileLink(link.id, other.id); // wrong file — no-op
|
||||
expect(svc.getFileLinks(file.id)).toHaveLength(1);
|
||||
svc.deleteFileLink(link.id, file.id);
|
||||
expect(svc.getFileLinks(file.id)).toHaveLength(0);
|
||||
await svc.deleteFileLink(link.id, other.id); // wrong file — no-op
|
||||
expect(await svc.getFileLinks(file.id)).toHaveLength(1);
|
||||
await svc.deleteFileLink(link.id, file.id);
|
||||
expect(await svc.getFileLinks(file.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── download auth & glue ──────────────────────────────────────────────────────
|
||||
|
||||
describe('authenticateDownload', () => {
|
||||
it('FILE-SVC-032: a valid session cookie wins over a bearer token', () => {
|
||||
it('FILE-SVC-032: a valid session cookie wins over a bearer token', async () => {
|
||||
verifyJwtAndLoadUser.mockReturnValue({ id: 42 });
|
||||
const result = svc.authenticateDownload(req({ cookie: 'cookie-jwt', bearer: 'bearer-jwt' }));
|
||||
const result = await svc.authenticateDownload(req({ cookie: 'cookie-jwt', bearer: 'bearer-jwt' }));
|
||||
expect(result).toEqual({ userId: 42 });
|
||||
expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('cookie-jwt');
|
||||
});
|
||||
|
||||
it('FILE-SVC-033: a bearer token is used when no cookie is present; invalid JWTs 401', () => {
|
||||
it('FILE-SVC-033: a bearer token is used when no cookie is present; invalid JWTs 401', async () => {
|
||||
verifyJwtAndLoadUser.mockReturnValue({ id: 7 });
|
||||
expect(svc.authenticateDownload(req({ bearer: 'bearer-jwt' }))).toEqual({ userId: 7 });
|
||||
expect(await svc.authenticateDownload(req({ bearer: 'bearer-jwt' }))).toEqual({ userId: 7 });
|
||||
expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('bearer-jwt');
|
||||
|
||||
verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
expect(svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 });
|
||||
expect(await svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 });
|
||||
});
|
||||
|
||||
it('FILE-SVC-034: a ?token= ephemeral token is consumed with the download purpose', () => {
|
||||
it('FILE-SVC-034: a ?token= ephemeral token is consumed with the download purpose', async () => {
|
||||
consumeEphemeralToken.mockReturnValue(9);
|
||||
expect(svc.authenticateDownload(req({ token: 'eph' }))).toEqual({ userId: 9 });
|
||||
expect(await svc.authenticateDownload(req({ token: 'eph' }))).toEqual({ userId: 9 });
|
||||
expect(consumeEphemeralToken).toHaveBeenCalledWith('eph', 'download');
|
||||
|
||||
consumeEphemeralToken.mockReturnValue(null);
|
||||
expect(svc.authenticateDownload(req({ token: 'spent' }))).toEqual({ error: 'Invalid or expired token', status: 401 });
|
||||
expect(await svc.authenticateDownload(req({ token: 'spent' }))).toEqual({ error: 'Invalid or expired token', status: 401 });
|
||||
});
|
||||
|
||||
it('FILE-SVC-035: no credentials at all is a 401 Authentication required', () => {
|
||||
expect(svc.authenticateDownload(req({}))).toEqual({ error: 'Authentication required', status: 401 });
|
||||
it('FILE-SVC-035: no credentials at all is a 401 Authentication required', async () => {
|
||||
expect(await svc.authenticateDownload(req({}))).toEqual({ error: 'Authentication required', status: 401 });
|
||||
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
||||
expect(consumeEphemeralToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTripAccess / can / files.bridge', () => {
|
||||
it('FILE-SVC-036: verifyTripAccess resolves owner and member, refuses strangers', () => {
|
||||
it('FILE-SVC-036: verifyTripAccess resolves owner and member, refuses strangers', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
expect(svc.verifyTripAccess(trip.id, owner.id)?.id).toBe(trip.id);
|
||||
expect(svc.verifyTripAccess(trip.id, member.id)).toBeDefined();
|
||||
expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
expect((await svc.verifyTripAccess(trip.id, owner.id))?.id).toBe(trip.id);
|
||||
expect(await svc.verifyTripAccess(trip.id, member.id)).toBeDefined();
|
||||
expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('FILE-SVC-037: can() forwards to checkPermission with the shared-trip flag', async () => {
|
||||
@@ -611,13 +611,13 @@ describe('verifyTripAccess / can / files.bridge', () => {
|
||||
expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false);
|
||||
});
|
||||
|
||||
it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', () => {
|
||||
it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', async () => {
|
||||
// Was asserted against files.bridge, which built its own FilesService. The
|
||||
// list moved to a leaf service the multer factories inject, and the property
|
||||
// that matters is unchanged: an admin editing the list in settings applies
|
||||
// to the next upload, with no invalidation wiring.
|
||||
const allowed = new AllowedFileTypesService(new DatabaseService(testDb));
|
||||
setAppSetting(testDb, 'allowed_file_types', 'md,markdown');
|
||||
expect(allowed.get()).toBe('md,markdown');
|
||||
expect(await allowed.get()).toBe('md,markdown');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,9 +54,9 @@ function ctx(request: Record<string, unknown>) {
|
||||
} as never;
|
||||
}
|
||||
|
||||
const thrown = (run: () => unknown) => {
|
||||
const thrown = async (run: () => unknown) => {
|
||||
try {
|
||||
run();
|
||||
await run();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
@@ -68,9 +68,9 @@ const thrown = (run: () => unknown) => {
|
||||
const ENFORCED: Rows = { requireMfa: 'true', mfaEnabled: 0 };
|
||||
|
||||
describe('MfaPolicyGuard', () => {
|
||||
it('MFA-001: refuses a user without MFA while the policy is on, with the legacy body', () => {
|
||||
it('MFA-001: refuses a user without MFA while the policy is on, with the legacy body', async () => {
|
||||
const { guard } = makeGuard(ENFORCED);
|
||||
expect(thrown(() => guard.canActivate(ctx({ user })))).toEqual({
|
||||
expect(await thrown(() => guard.canActivate(ctx({ user })))).toEqual({
|
||||
status: 403,
|
||||
body: {
|
||||
error: 'Two-factor authentication is required. Complete setup in Settings.',
|
||||
@@ -79,68 +79,68 @@ describe('MfaPolicyGuard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('MFA-002: lets a user with TOTP through', () => {
|
||||
it('MFA-002: lets a user with TOTP through', async () => {
|
||||
const { guard } = makeGuard({ requireMfa: 'true', mfaEnabled: 1 });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-003: a user-verified passkey satisfies the policy like TOTP does', () => {
|
||||
it('MFA-003: a user-verified passkey satisfies the policy like TOTP does', async () => {
|
||||
const { guard } = makeGuard({ requireMfa: 'true', mfaEnabled: 0, hasPasskey: true });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-004: does nothing when the policy is off, and never reads the users row', () => {
|
||||
it('MFA-004: does nothing when the policy is off, and never reads the users row', async () => {
|
||||
const { guard, get } = makeGuard({ requireMfa: 'false', mfaEnabled: 0 });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(get.mock.calls.some((c) => String(c[0]).includes('FROM users'))).toBe(false);
|
||||
});
|
||||
|
||||
it('MFA-005: does nothing when the setting row is absent at all', () => {
|
||||
it('MFA-005: does nothing when the setting row is absent at all', async () => {
|
||||
const { guard } = makeGuard({ mfaEnabled: 0 });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-006: an anonymous request is not the policy business', () => {
|
||||
it('MFA-006: an anonymous request is not the policy business', async () => {
|
||||
const { guard, get } = makeGuard(ENFORCED);
|
||||
expect(guard.canActivate(ctx({}))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({}))).toBe(true);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MFA-007: the demo account is exempt while demo mode is on', () => {
|
||||
it('MFA-007: the demo account is exempt while demo mode is on', async () => {
|
||||
const demoUser = { id: 1, email: DEMO_EMAIL_PRIMARY, role: 'user' } as User;
|
||||
expect(makeGuard(ENFORCED, {}, true).guard.canActivate(ctx({ user: demoUser }))).toBe(true);
|
||||
expect(await makeGuard(ENFORCED, {}, true).guard.canActivate(ctx({ user: demoUser }))).toBe(true);
|
||||
// …and not otherwise.
|
||||
expect(thrown(() => makeGuard(ENFORCED, {}, false).guard.canActivate(ctx({ user: demoUser })))).toEqual({
|
||||
expect(await thrown(() => makeGuard(ENFORCED, {}, false).guard.canActivate(ctx({ user: demoUser })))).toEqual({
|
||||
status: 403,
|
||||
body: expect.objectContaining({ code: 'MFA_REQUIRED' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('MFA-008: a user row that vanished mid-request is let through, not 403d', () => {
|
||||
it('MFA-008: a user row that vanished mid-request is let through, not 403d', async () => {
|
||||
const { guard } = makeGuard({ requireMfa: 'true', userMissing: true });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-009: @MfaExempt is the way out — setup would be unreachable otherwise', () => {
|
||||
it('MFA-009: @MfaExempt is the way out — setup would be unreachable otherwise', async () => {
|
||||
const { guard } = makeGuard(ENFORCED, { exempt: true });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-010: it reads req.user rather than verifying the token again', () => {
|
||||
it('MFA-010: it reads req.user rather than verifying the token again', async () => {
|
||||
// The middleware this replaces called jwt.verify and SELECTed the users row a
|
||||
// second time on every /api request, then discarded the result.
|
||||
const { guard, get } = makeGuard({ requireMfa: 'true', mfaEnabled: 1 });
|
||||
guard.canActivate(ctx({ user, headers: { authorization: 'Bearer nonsense' } }));
|
||||
await guard.canActivate(ctx({ user, headers: { authorization: 'Bearer nonsense' } }));
|
||||
expect(get.mock.calls.map((c) => c[0]).every((sql) => !String(sql).includes('password_version'))).toBe(true);
|
||||
});
|
||||
|
||||
it('MFA-011: a @Public route no longer 403s a logged-in user without MFA', () => {
|
||||
it('MFA-011: a @Public route no longer 403s a logged-in user without MFA', async () => {
|
||||
// The correction. /api/config, /api/help/*, the public journey and share
|
||||
// routes and /api/health/features all answered a stranger fine and answered
|
||||
// this user with a 403, because they were added after the middleware path
|
||||
// lists were written and nobody noticed.
|
||||
const { guard, get } = makeGuard(ENFORCED, { public: true });
|
||||
expect(guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(await guard.canActivate(ctx({ user }))).toBe(true);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1178,12 +1178,12 @@ describe('verifyIdToken', () => {
|
||||
// ── wrapper methods (carried over from the delegation-shim suite) ────────────
|
||||
|
||||
describe('wrapper methods', () => {
|
||||
it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', () => {
|
||||
it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', async () => {
|
||||
const spy = vi.spyOn(auth, 'resolveAuthToggles');
|
||||
spy.mockReturnValue({ oidc_login: true } as ReturnType<AuthService['resolveAuthToggles']>);
|
||||
expect(svc.oidcLoginEnabled()).toBe(true);
|
||||
spy.mockReturnValue({ oidc_login: false } as ReturnType<AuthService['resolveAuthToggles']>);
|
||||
expect(svc.oidcLoginEnabled()).toBe(false);
|
||||
spy.mockResolvedValue({ oidc_login: true } as Awaited<ReturnType<AuthService['resolveAuthToggles']>>);
|
||||
expect(await svc.oidcLoginEnabled()).toBe(true);
|
||||
spy.mockResolvedValue({ oidc_login: false } as Awaited<ReturnType<AuthService['resolveAuthToggles']>>);
|
||||
expect(await svc.oidcLoginEnabled()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1222,22 +1222,22 @@ describe('OIDC settings', () => {
|
||||
expect(result.discovery_url).toBe('');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => {
|
||||
svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||||
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', async () => {
|
||||
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||||
const result = svc.getOidcSettings() as any;
|
||||
expect(result.issuer).toBe('https://auth.example.com');
|
||||
expect(result.client_id).toBe('my-client');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', () => {
|
||||
svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||||
it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', async () => {
|
||||
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||||
const result = svc.getOidcSettings() as any;
|
||||
// oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles
|
||||
expect(result.oidc_only).toBe(false);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', () => {
|
||||
const result = svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any;
|
||||
it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', async () => {
|
||||
const result = await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any;
|
||||
expect(result.success).toBe(true);
|
||||
const settings = svc.getOidcSettings();
|
||||
expect(settings).toMatchObject({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' });
|
||||
@@ -1245,31 +1245,31 @@ describe('OIDC settings', () => {
|
||||
});
|
||||
|
||||
describe('OIDC settings — the lockout guard', () => {
|
||||
it('OIDC-SETTINGS-050 refuses to clear the config while password login is off', () => {
|
||||
it('OIDC-SETTINGS-050 refuses to clear the config while password login is off', async () => {
|
||||
// Clearing the issuer with password login disabled locks every user out of the
|
||||
// instance: no SSO to log in through, and no password form either.
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: false } as never);
|
||||
expect(svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 });
|
||||
expect(svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 });
|
||||
expect((svc.updateOidcSettings({ issuer: '', client_id: '' }) as { error?: string }).error).toMatch(/password login/i);
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: false } as never);
|
||||
expect(await svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 });
|
||||
expect(await svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 });
|
||||
expect(((await svc.updateOidcSettings({ issuer: '', client_id: '' })) as { error?: string }).error).toMatch(/password login/i);
|
||||
toggles.mockRestore();
|
||||
});
|
||||
|
||||
it('OIDC-SETTINGS-051 allows the same clear once password login is back on', () => {
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never);
|
||||
expect(svc.updateOidcSettings({ issuer: '', client_id: '' })).toEqual({ success: true });
|
||||
it('OIDC-SETTINGS-051 allows the same clear once password login is back on', async () => {
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never);
|
||||
expect(await svc.updateOidcSettings({ issuer: '', client_id: '' })).toEqual({ success: true });
|
||||
toggles.mockRestore();
|
||||
});
|
||||
|
||||
it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', () => {
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never);
|
||||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' });
|
||||
it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', async () => {
|
||||
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never);
|
||||
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' });
|
||||
expect(svc.getOidcSettings().client_secret_set).toBe(true);
|
||||
// Omitted: the write skips the column entirely rather than blanking it, which is
|
||||
// what lets the admin panel save the form without re-typing the secret.
|
||||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' });
|
||||
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' });
|
||||
expect(svc.getOidcSettings().client_secret_set).toBe(true);
|
||||
svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' });
|
||||
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' });
|
||||
expect(svc.getOidcSettings().client_secret_set).toBe(false);
|
||||
toggles.mockRestore();
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ beforeAll(async () => {
|
||||
new EphemeralTokenService(),
|
||||
new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb),
|
||||
);
|
||||
svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn);
|
||||
svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn, await createTestUnitOfWork(testDb));
|
||||
});
|
||||
|
||||
const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'], explicitOrigins: false };
|
||||
@@ -660,7 +660,7 @@ describe('passkeyLoginVerify', () => {
|
||||
// ── listPasskeys ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listPasskeys', () => {
|
||||
it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', () => {
|
||||
it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
@@ -669,7 +669,7 @@ describe('listPasskeys', () => {
|
||||
).run(user.id, user.id);
|
||||
insertCredential(other.id);
|
||||
|
||||
const list = svc.listPasskeys(user.id);
|
||||
const list = await svc.listPasskeys(user.id);
|
||||
expect(list.map((c) => c.name)).toEqual(['New', 'Old']);
|
||||
expect(list[0]).toEqual({
|
||||
id: expect.any(Number),
|
||||
@@ -686,47 +686,47 @@ describe('listPasskeys', () => {
|
||||
// ── renamePasskey ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renamePasskey', () => {
|
||||
it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', () => {
|
||||
it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cred = insertCredential(user.id);
|
||||
expect(svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 });
|
||||
expect(svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 });
|
||||
expect(svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 });
|
||||
expect(await svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 });
|
||||
expect(await svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 });
|
||||
expect(await svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 });
|
||||
});
|
||||
|
||||
it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', () => {
|
||||
it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const cred = insertCredential(user.id);
|
||||
|
||||
expect(svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true });
|
||||
expect(await svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true });
|
||||
const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = ?').get(cred.id) as { name: string };
|
||||
expect(row.name).toBe('n'.repeat(60));
|
||||
|
||||
expect(svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
expect(svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
expect(await svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── deletePasskey ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deletePasskey', () => {
|
||||
it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', () => {
|
||||
it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const cred = insertCredential(user.id);
|
||||
expect(svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
expect(svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
expect(await svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
expect(await svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
testDb.prepare("UPDATE users SET password_hash = '' WHERE id = ?").run(user.id);
|
||||
expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 });
|
||||
});
|
||||
|
||||
it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', () => {
|
||||
it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const { user: other, password: otherPassword } = createUser(testDb);
|
||||
const cred = insertCredential(user.id);
|
||||
|
||||
expect(svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true });
|
||||
expect(await svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 });
|
||||
expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -734,8 +734,8 @@ describe('deletePasskey', () => {
|
||||
// ── adminResetPasskeys ────────────────────────────────────────────────────────
|
||||
|
||||
describe('adminResetPasskeys', () => {
|
||||
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', () => {
|
||||
expect(svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 });
|
||||
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => {
|
||||
expect(await svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 });
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
@@ -743,8 +743,8 @@ describe('adminResetPasskeys', () => {
|
||||
insertCredential(user.id);
|
||||
const kept = insertCredential(other.id);
|
||||
|
||||
expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email });
|
||||
expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email });
|
||||
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email });
|
||||
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email });
|
||||
expect(testDb.prepare('SELECT id FROM webauthn_credentials').all()).toEqual([{ id: kept.id }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,48 +36,48 @@ afterAll(() => { testDb.close(); });
|
||||
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Invites', () => {
|
||||
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
|
||||
it('ADMIN-SVC-024 — createInvite returns invite with token', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = svc.createInvite(admin.id, { max_uses: 5 }) as any;
|
||||
const result = await svc.createInvite(admin.id, { max_uses: 5 }) as any;
|
||||
expect(result.invite.token).toBeDefined();
|
||||
expect(result.invite.max_uses).toBe(5);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
|
||||
it('ADMIN-SVC-025 — createInvite defaults to 1 use', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = svc.createInvite(admin.id, {}) as any;
|
||||
const result = await svc.createInvite(admin.id, {}) as any;
|
||||
expect(result.uses).toBe(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-026 — listInvites returns array', () => {
|
||||
it('ADMIN-SVC-026 — listInvites returns array', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
svc.createInvite(admin.id, {});
|
||||
const invites = svc.listInvites() as any[];
|
||||
await svc.createInvite(admin.id, {});
|
||||
const invites = await svc.listInvites() as any[];
|
||||
expect(invites.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
|
||||
it('ADMIN-SVC-027 — deleteInvite removes invite', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
|
||||
const result = svc.deleteInvite(String(invite.id)) as any;
|
||||
const result = await svc.deleteInvite(String(invite.id)) as any;
|
||||
expect(result.error).toBeUndefined();
|
||||
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
|
||||
expect(check).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
|
||||
const result = svc.deleteInvite('99999') as any;
|
||||
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => {
|
||||
const result = await svc.deleteInvite('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invites — trip binding', () => {
|
||||
it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => {
|
||||
it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
expect(svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' });
|
||||
expect(svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 });
|
||||
expect(await svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' });
|
||||
expect(await svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 });
|
||||
// An absent/blank binding is still a plain registration invite.
|
||||
expect((svc.createInvite(admin.id, {}) as any).tripId).toBeNull();
|
||||
expect((await svc.createInvite(admin.id, {}) as any).tripId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,12 @@ describe('roadtrip read tools', () => {
|
||||
});
|
||||
it('passes reviewed Google stops and the authenticated user to the importer', async () => {
|
||||
const routes = { preview: vi.fn(async () => ({ stops: [] })), import: vi.fn(() => ({ imported: 2 })) };
|
||||
const auth = { isDemoUser: vi.fn(() => false) };
|
||||
const auth = { isDemoUser: vi.fn(async () => false) };
|
||||
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
|
||||
await tool.preview({ url: 'https://www.google.com/maps/dir/A/B' });
|
||||
expect(routes.preview).toHaveBeenCalledWith('https://www.google.com/maps/dir/A/B');
|
||||
const input = { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] };
|
||||
expect(JSON.stringify(tool.import(input, ctx))).toContain('2');
|
||||
expect(JSON.stringify(await tool.import(input, ctx))).toContain('2');
|
||||
expect(routes.import).toHaveBeenCalledWith(10, 5, input);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('roadtrip read tools', () => {
|
||||
// The one non-admin write tool that had no gate: a demo session could write
|
||||
// thirty places and their assignments onto the shared demo trip.
|
||||
const routes = { preview: vi.fn(), import: vi.fn() };
|
||||
const auth = { isDemoUser: vi.fn(() => true) };
|
||||
const auth = { isDemoUser: vi.fn(async () => true) };
|
||||
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
|
||||
|
||||
const res = await tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx);
|
||||
@@ -55,7 +55,7 @@ describe('roadtrip read tools', () => {
|
||||
const charging = new ChargingMcp({ read: refuse('Place not found', 404) } as never, db as never, {} as never);
|
||||
expect(text(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toEqual([true, 'Place not found']);
|
||||
|
||||
const auth = { isDemoUser: vi.fn(() => false) };
|
||||
const auth = { isDemoUser: vi.fn(async () => false) };
|
||||
const routes = { preview: refuse('Use a Google Maps directions link.', 400), import: refuse('Permission denied', 403) };
|
||||
const google = new GoogleRouteMcp(routes as never, auth as never, {} as never);
|
||||
expect(text(await google.preview({ url: 'https://www.google.com/maps/place/A' }))).toEqual([true, 'Use a Google Maps directions link.']);
|
||||
|
||||
@@ -52,14 +52,14 @@ function handler(): CallHandler & { handle: ReturnType<typeof vi.fn> } {
|
||||
}
|
||||
|
||||
function makeInterceptor() {
|
||||
const generateToken = vi.fn().mockReturnValue('renewed.jwt');
|
||||
const generateToken = vi.fn().mockResolvedValue('renewed.jwt');
|
||||
const interceptor = new SessionRenewalInterceptor({ generateToken } as unknown as AuthService);
|
||||
return { interceptor, generateToken };
|
||||
}
|
||||
|
||||
async function run(interceptor: SessionRenewalInterceptor, req: ReqShape, res: ReturnType<typeof makeRes>, type = 'http') {
|
||||
const h = handler();
|
||||
const out = await lastValueFrom(interceptor.intercept(ctx(req, res, type), h));
|
||||
const out = await lastValueFrom(await interceptor.intercept(ctx(req, res, type), h));
|
||||
expect(out).toBe('ok');
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('erasePluginUserData', () => {
|
||||
it('USER-CLEANUP-001: deletes the host-side per-user plugin rows', () => {
|
||||
it('USER-CLEANUP-001: deletes the host-side per-user plugin rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb, { username: 'other' });
|
||||
installPlugin('demo', []);
|
||||
@@ -103,34 +103,34 @@ describe('erasePluginUserData', () => {
|
||||
testDb.prepare('INSERT INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)')
|
||||
.run('demo', other.id, '{"token":"keep-me"}');
|
||||
|
||||
svc.erasePluginUserData(user.id);
|
||||
await svc.erasePluginUserData(user.id);
|
||||
|
||||
const rows = testDb.prepare('SELECT user_id FROM plugin_user_config').all() as Array<{ user_id: number }>;
|
||||
expect(rows.map(r => r.user_id)).toEqual([other.id]);
|
||||
});
|
||||
|
||||
it('USER-CLEANUP-002: enqueues an erasure only for plugins holding hook:user-data', () => {
|
||||
it('USER-CLEANUP-002: enqueues an erasure only for plugins holding hook:user-data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
installPlugin('with-hook', ['hook:user-data', 'trips:read']);
|
||||
installPlugin('without-hook', ['trips:read']);
|
||||
installPlugin('no-permissions', null);
|
||||
|
||||
svc.erasePluginUserData(user.id);
|
||||
await svc.erasePluginUserData(user.id);
|
||||
|
||||
expect(queuedFor(user.id)).toEqual(['with-hook']);
|
||||
});
|
||||
|
||||
it('USER-CLEANUP-003: treats an unparseable permissions column as no permissions', () => {
|
||||
it('USER-CLEANUP-003: treats an unparseable permissions column as no permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO plugins (id, name, version, permissions) VALUES (?, ?, ?, ?)')
|
||||
.run('broken', 'broken', '1.0.0', '{not json');
|
||||
|
||||
svc.erasePluginUserData(user.id);
|
||||
await svc.erasePluginUserData(user.id);
|
||||
|
||||
expect(queuedFor(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('USER-CLEANUP-004: enqueues every orphan data dir — an uninstall keeps no permissions row', () => {
|
||||
it('USER-CLEANUP-004: enqueues every orphan data dir — an uninstall keeps no permissions row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-plugins-data-'));
|
||||
dataRootRef.value = root;
|
||||
@@ -140,7 +140,7 @@ describe('erasePluginUserData', () => {
|
||||
fs.mkdirSync(path.join(root, 'installed'));
|
||||
|
||||
try {
|
||||
svc.erasePluginUserData(user.id);
|
||||
await svc.erasePluginUserData(user.id);
|
||||
// 'installed' comes from the permissions scan, not the orphan scan, and the
|
||||
// INSERT OR IGNORE keeps it single.
|
||||
expect(queuedFor(user.id)).toEqual(['installed', 'uninstalled-but-retained']);
|
||||
@@ -155,7 +155,7 @@ describe('erasePluginUserData', () => {
|
||||
slim.prepare('INSERT INTO users (id) VALUES (1)').run();
|
||||
const slimSvc = new UserCleanupService(new DatabaseService(slim), budget, await createTestUnitOfWork(testDb));
|
||||
|
||||
expect(() => slimSvc.erasePluginUserData(1)).not.toThrow();
|
||||
await expect(slimSvc.erasePluginUserData(1)).resolves.toBeUndefined();
|
||||
|
||||
slim.close();
|
||||
});
|
||||
@@ -216,6 +216,10 @@ describe('deleteUserCompletely', () => {
|
||||
|
||||
// Fail on the final statement only, after the reference cleanup has written.
|
||||
const failing = new DatabaseService(testDb);
|
||||
// `.bind` is load-bearing here and must NOT become an arrow forwarder: it captures
|
||||
// the ORIGINAL run before vi.spyOn replaces the property, whereas `(...a) => failing.run(...a)`
|
||||
// would re-enter the spy and recurse. `run` stays synchronous (recipe R0), so the
|
||||
// `any`-typed alias hides nothing.
|
||||
const realRun = failing.run.bind(failing);
|
||||
vi.spyOn(failing, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
|
||||
if (sql.startsWith('DELETE FROM users')) throw new Error('boom');
|
||||
|
||||
@@ -170,21 +170,21 @@ describe('getSettings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('AUTH-DB-011: returns all users except self, sorted by username', () => {
|
||||
it('AUTH-DB-011: returns all users except self, sorted by username', async () => {
|
||||
const { user: self } = createUser(testDb, { username: 'zzself' });
|
||||
createUser(testDb, { username: 'alice' });
|
||||
createUser(testDb, { username: 'charlie' });
|
||||
createUser(testDb, { username: 'bob' });
|
||||
const result = profile.listUsers(self.id);
|
||||
const result = await profile.listUsers(self.id);
|
||||
expect(result).toHaveLength(3);
|
||||
const names = result.map((u) => u.username);
|
||||
expect(names).toEqual([...names].sort());
|
||||
expect(names).not.toContain('zzself');
|
||||
});
|
||||
|
||||
it('AUTH-DB-012: returns empty array when only one user exists', () => {
|
||||
it('AUTH-DB-012: returns empty array when only one user exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = profile.listUsers(user.id);
|
||||
const result = await profile.listUsers(user.id);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,87 +49,87 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('WebauthnConfigService.resolve', () => {
|
||||
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => {
|
||||
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', async () => {
|
||||
appUrlRef.value = 'https://trek.example.org';
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.rpID).toBe('trek.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
||||
expect(cfg!.explicitOrigins).toBe(false); // derived, not operator-supplied
|
||||
expect(svc.isConfigured()).toBe(true);
|
||||
expect(await svc.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => {
|
||||
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', async () => {
|
||||
appUrlRef.value = 'http://192.168.1.50:3001';
|
||||
expect(svc.resolve()).toBeNull();
|
||||
expect(svc.isConfigured()).toBe(false);
|
||||
expect(await svc.resolve()).toBeNull();
|
||||
expect(await svc.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', () => {
|
||||
it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', async () => {
|
||||
// getAppUrl() never yields '' — an unconfigured instance lands on
|
||||
// http://localhost:{PORT}, so resolve() returns the localhost dev config.
|
||||
// The remote-origin guard for that phantom config lives in PasskeyService.
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.rpID).toBe('localhost');
|
||||
expect(cfg!.explicitOrigins).toBe(false);
|
||||
expect(svc.isConfigured()).toBe(true);
|
||||
expect(await svc.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => {
|
||||
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', async () => {
|
||||
appUrlRef.value = 'http://localhost:3001';
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.rpID).toBe('localhost');
|
||||
expect(cfg!.origins).toContain('http://localhost:5173');
|
||||
expect(cfg!.origins).toContain('http://localhost:3001');
|
||||
});
|
||||
|
||||
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => {
|
||||
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', async () => {
|
||||
appUrlRef.value = 'https://internal.example.org';
|
||||
settingsStore.set('webauthn_rp_id', 'public.example.org');
|
||||
settingsStore.set('webauthn_origins', 'https://public.example.org');
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.rpID).toBe('public.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://public.example.org']);
|
||||
expect(cfg!.explicitOrigins).toBe(true);
|
||||
});
|
||||
|
||||
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => {
|
||||
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', async () => {
|
||||
settingsStore.set('webauthn_rp_id', 'example.org');
|
||||
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']);
|
||||
});
|
||||
|
||||
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => {
|
||||
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', async () => {
|
||||
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
||||
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
||||
appUrlRef.value = 'https://ignored.example.org';
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.rpID).toBe('env.example.org');
|
||||
expect(cfg!.origins).toEqual(['https://env.example.org']);
|
||||
});
|
||||
|
||||
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => {
|
||||
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', async () => {
|
||||
appUrlRef.value = 'https://trek.example.org';
|
||||
settingsStore.set('webauthn_rp_id', 'trek.example.org');
|
||||
const cfg = svc.resolve();
|
||||
const cfg = await svc.resolve();
|
||||
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
||||
expect(cfg!.explicitOrigins).toBe(false); // the origin list itself is still derived
|
||||
});
|
||||
|
||||
it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', () => {
|
||||
it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', async () => {
|
||||
appUrlRef.value = 'https://trek.example.org';
|
||||
expect(svc.resolve()!.rpID).toBe('trek.example.org');
|
||||
expect((await svc.resolve())!.rpID).toBe('trek.example.org');
|
||||
|
||||
settingsStore.set('webauthn_rp_id', 'moved.example.org');
|
||||
|
||||
expect(svc.resolve()!.rpID).toBe('moved.example.org');
|
||||
expect((await svc.resolve())!.rpID).toBe('moved.example.org');
|
||||
});
|
||||
|
||||
it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', () => {
|
||||
it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', async () => {
|
||||
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
||||
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
||||
expect(svc.resolve()!.explicitOrigins).toBe(true);
|
||||
expect((await svc.resolve())!.explicitOrigins).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user