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

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