refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-21 20:48:03 +02:00
parent 27d044f9e8
commit 0cf99a9939
306 changed files with 3656 additions and 3189 deletions
+2 -2
View File
@@ -18,6 +18,6 @@ import { getPluginMcpToolSource } from '../plugin-mcp-tools';
* exist and an app built without the plugins runtime resolves it to undefined.
* Tests pass it explicitly instead of reaching for the global.
*/
export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): void {
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools });
export async function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): Promise<void> {
if (registry) await registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools });
}
+8 -8
View File
@@ -115,10 +115,10 @@ export class McpRegistry {
* `ctx` as the handler's last argument (in the SDK `extra` slot). `opts`
* carries per-session hooks — see `McpAttachOptions`.
*/
attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): void {
async attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): Promise<void> {
const registrar = server as unknown as LooseRegistrar;
for (const { entry, instance } of this.bound) {
if (!this.allowed(entry, ctx, instance)) continue;
if (!(await this.allowed(entry, ctx, instance))) continue;
const handler = (instance as unknown as Record<string, AnyHandler>)[entry.methodName];
switch (entry.kind) {
case 'tool':
@@ -142,7 +142,7 @@ export class McpRegistry {
// tools/list is insertion-ordered, so host-contributed tools sort last,
// which is the right priority signal in a long list; and it reads the way
// it works — the registry, then whatever this session added on top.
if (opts?.dynamicTools) this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools);
if (opts?.dynamicTools) await this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools);
}
/**
@@ -161,12 +161,12 @@ export class McpRegistry {
return this.reserved;
}
private attachDynamicTools(
private async attachDynamicTools(
registrar: LooseRegistrar,
ctx: McpContext,
opts: McpAttachOptions,
source: McpDynamicToolSource,
): void {
): Promise<void> {
let tools: readonly McpDynamicTool[];
try {
tools = source(ctx) ?? [];
@@ -197,7 +197,7 @@ export class McpRegistry {
// entries only avoid it because validate() pre-checks them at boot, and
// a per-session source has no boot to be checked at.
const entry: McpEntry = { kind: 'tool', methodName: DYNAMIC_METHOD_NAME, options: tool.options };
if (!this.allowed(entry, ctx, owner)) continue;
if (!(await this.allowed(entry, ctx, owner))) continue;
this.attachTool(registrar, tool.options, owner, tool.handler as AnyHandler, ctx, opts);
} catch (err) {
console.warn(`[nest-mcp] skipped dynamic tool "${String(name)}": ${describeError(err)}`);
@@ -263,12 +263,12 @@ export class McpRegistry {
};
}
private allowed(entry: McpEntry, ctx: McpContext, instance: object): boolean {
private async allowed(entry: McpEntry, ctx: McpContext, instance: object): Promise<boolean> {
// The declaring instance goes in so the gate can read an injected
// collaborator. It is resolved here, at attach, rather than captured when
// the options object was built — the class body runs long before the
// container exists.
if (entry.options.when && !entry.options.when(ctx, instance)) return false;
if (entry.options.when && !(await entry.options.when(ctx, instance))) return false;
const access = entry.options.access;
if (access === undefined) return true;
if (typeof access === 'function') return access(ctx);
+1 -1
View File
@@ -103,7 +103,7 @@ interface McpEntryOptionsBase {
* so a predicate written against its own controller class stays assignable
* here. The registry always passes the instance that declared the entry.
*/
when?(ctx: McpContext, self: object): boolean;
when?(ctx: McpContext, self: object): boolean | Promise<boolean>;
/** Omitted ⇒ the entry is always registered (subject to `when`). */
access?: McpAccess;
}
@@ -74,7 +74,7 @@ export class AccommodationsController {
@RequirePermission('day_edit')
@Post()
create(
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() rawBody: AccommodationCreateDto,
@@ -89,10 +89,10 @@ export class AccommodationsController {
if (errors.length > 0) {
throw new HttpException({ error: errors[0].message }, 404);
}
const { accommodation, mirror } = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
const { accommodation, mirror } = await this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId);
this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId);
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
// The stop rides in the answer as well, for the session that booked the night
// with its socket down: over the socket it would already have it.
return { accommodation, assignment: mirror.created };
@@ -100,7 +100,7 @@ export class AccommodationsController {
@RequirePermission('day_edit')
@Put(':id')
update(
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -117,15 +117,15 @@ export class AccommodationsController {
if (errors.length > 0) {
throw new HttpException({ error: errors[0].message }, 404);
}
const { accommodation, mirror } = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
const { accommodation, mirror } = await this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId);
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
return { accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed };
}
@RequirePermission('day_edit')
@Delete(':id')
remove(
async remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -138,8 +138,8 @@ export class AccommodationsController {
// Turning a night back into a pause in road trip mode: the booking goes, the
// stop stays and becomes the traveller's. Everywhere else a cancelled booking
// takes the stop it brought with it.
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.accommodations.remove(id, { keepStop: keepStop === 'true' });
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.accommodations.remove(id, { keepStop: keepStop === 'true' });
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
for (const reservationId of linkedReservationIds) {
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
}
@@ -9,6 +9,7 @@ import { AuthService } from '../auth/auth.service';
import { PlacesService } from '../places/places.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AccommodationsService, type MirrorSender } from './accommodations.service';
function parseId(value: string | string[]): number | null {
@@ -34,6 +35,7 @@ export class AccommodationsMcp {
private readonly places: PlacesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly uow: UnitOfWork,
) {}
/** The tools' own broadcast, handed to announceMirror so the day stop a booking
@@ -68,12 +70,12 @@ export class AccommodationsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
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);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation });
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
return ok({ accommodation, assignment: mirror.created });
}
@@ -119,22 +121,22 @@ export class AccommodationsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
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);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
const result = this.db.transaction(() => {
const result = await this.uow.transactional(async () => {
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation, mirror };
});
// The stamped copy, not the one create() returned: booking the night types the
// place as lodging, and place:created is the only announcement it gets here.
const place = result.mirror.stamped ?? result.place;
const place = (await result.mirror).stamped ?? result.place;
this.guards.safeBroadcast(tripId, 'place:created', { place });
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId));
return ok({ place, accommodation: result.accommodation, assignment: result.mirror.created });
await this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId));
return ok({ place, accommodation: result.accommodation, assignment: (await result.mirror).created });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
}
@@ -167,12 +169,12 @@ export class AccommodationsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.accommodations.getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { accommodation, mirror } = this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
const { accommodation, mirror } = await this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
this.guards.safeBroadcast(tripId, 'accommodation:updated', { accommodation });
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
// movedAssignment rather than a delete/create pair: an edit carries the booking's
// own stop across instead of rebuilding it, so the caller sees the same row.
return ok({ accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed });
@@ -191,12 +193,12 @@ export class AccommodationsMcp {
async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
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 };
// linkedReservationId stays the first one so the tool's answer keeps its shape;
// linkedReservationIds carries the rest for a block that had more than one booking.
const { linkedReservationId, linkedReservationIds, mirror } = this.accommodations.deleteAccommodation(accommodationId);
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
const { linkedReservationId, linkedReservationIds, mirror } = await this.accommodations.deleteAccommodation(accommodationId);
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId, linkedReservationIds });
return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed });
}
@@ -42,12 +42,12 @@ export class AccommodationsRpc {
) {}
@PluginMethod('accommodations.create', { permission: 'db:write:accommodations' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'accommodation');
const parsed = accommodationCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
const input = parsed.data as AccommodationInput;
const placeId = Math.trunc(Number(input.place_id));
const startDayId = Math.trunc(Number(input.start_day_id));
@@ -56,7 +56,7 @@ export class AccommodationsRpc {
// Verifies the place and both days belong to this trip.
const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
const { accommodation, mirror } = this.days.createAccommodation(tripId, {
const { accommodation, mirror } = await this.days.createAccommodation(tripId, {
place_id: placeId,
start_day_id: startDayId,
end_day_id: endDayId,
@@ -69,41 +69,41 @@ export class AccommodationsRpc {
this.realtime.broadcast(tripId, 'accommodation:created', { accommodation });
// The block creates a partner hotel reservation, so the bookings view refreshes too.
this.realtime.broadcast(tripId, 'reservation:created', {});
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
return accommodation;
}
@PluginMethod('accommodations.update', { permission: 'db:write:accommodations' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const accommodationId = num(params.accommodationId, 'accommodationId');
const actor = this.guards.requireActor(ctx, 'accommodation');
const parsed = accommodationUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
const existing = this.days.getAccommodation(accommodationId, tripId);
if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
const input = parsed.data as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string };
const errors = this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id);
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
const { accommodation, mirror } = this.days.updateAccommodation(accommodationId, existing, input);
const { accommodation, mirror } = await this.days.updateAccommodation(accommodationId, existing, input);
this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation });
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
return accommodation;
}
@PluginMethod('accommodations.delete', { permission: 'db:write:accommodations' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const accommodationId = num(params.accommodationId, 'accommodationId');
const actor = this.guards.requireActor(ctx, 'accommodation');
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
if (!this.days.getAccommodation(accommodationId, tripId)) {
throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
}
// Deleting a block can take its partner reservation and budget item with it.
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.days.deleteAccommodation(accommodationId);
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.days.deleteAccommodation(accommodationId);
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
for (const reservationId of linkedReservationIds) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId });
for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId });
this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId });
@@ -2,13 +2,14 @@ import { Injectable } from '@nestjs/common';
import type { RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { AssignmentsService } from '../assignments/assignments.service';
import type { User } from '../../types';
type Trip = TripAccess;
type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
type MirroredAssignment = Awaited<ReturnType<AssignmentsService['createAssignment']>>;
/** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */
type DayVias = { dayId: number; vias: RoadtripVia[] };
@@ -95,6 +96,7 @@ export class AccommodationsService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly assignments: AssignmentsService,
private readonly uow: UnitOfWork,
) {}
private get db() {
@@ -107,7 +109,7 @@ export class AccommodationsService {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -160,24 +162,24 @@ export class AccommodationsService {
// -------------------------------------------------------------------------
/** Put a freshly written stay on the map. */
attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
async attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise<AccommodationMirror> {
return this.mirrorStay(accommodationId, placeId, dayId, checkIn);
}
/** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the
* booking was given a new hour, which seats the night afresh (see remirrorStay). */
moveStayStop(
async moveStayStop(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
): Promise<AccommodationMirror> {
return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts);
}
/** Take back the stops of a stay that is being deleted elsewhere. */
dropStayStops(accommodationId: number): AccommodationMirror {
async dropStayStops(accommodationId: number): Promise<AccommodationMirror> {
return this.releaseStops(accommodationId, {});
}
@@ -191,7 +193,7 @@ export class AccommodationsService {
* sent the request. The day order and the vias sent here are news to that
* session too, and they only make sense arriving behind the stop they concern.
*/
announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): void {
async announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): Promise<void> {
for (const stop of mirror.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
if (mirror.created) send('assignment:created', { assignment: mirror.created });
if (mirror.moved) {
@@ -207,7 +209,7 @@ export class AccommodationsService {
// A night is seated by its check-in, which renumbers the stops around it. The
// created/moved event alone puts the row at the end of the day on every other
// screen, so the day that changed sends its order along.
for (const dayId of this.touchedDays(mirror)) {
for (const dayId of await this.touchedDays(mirror)) {
const orderedIds = this.db.all<{ id: number }>(
'SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index', dayId).map(row => row.id);
send('assignment:reordered', { dayId, orderedIds });
@@ -220,7 +222,7 @@ export class AccommodationsService {
}
/** Days whose stop order this write can have changed, each named once. */
private touchedDays(mirror: AccommodationMirror): number[] {
private async touchedDays(mirror: AccommodationMirror): Promise<number[]> {
const days = new Set<number>();
if (mirror.created) days.add(mirror.created.day_id);
if (mirror.moved) { days.add(mirror.moved.assignment.day_id); days.add(mirror.moved.oldDayId); }
@@ -479,12 +481,12 @@ export class AccommodationsService {
*
* Runs inside the caller's transaction.
*/
private relocateOwnStop(
private async relocateOwnStop(
stop: { id: number; day_id: number; order_index: number },
placeId: number,
dayId: number,
checkIn?: string | null,
): MirroredAssignment | null {
): Promise<MirroredAssignment | null> {
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) {
return null;
}
@@ -516,7 +518,7 @@ export class AccommodationsService {
return this.assignments.getAssignmentWithPlace(stop.id);
}
private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
private async mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise<AccommodationMirror> {
const mirror = noMirror();
// A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking
// form writes stays that never had one. Nothing to put on the map then.
@@ -539,7 +541,7 @@ export class AccommodationsService {
// would leave that copy without it. The day list has nothing else to tell the
// stop from a place the traveller added, so it would show the hotel a second
// time until the next reload.
mirror.created = this.assignments.createAssignment(dayId, placeId, null, {
mirror.created = await this.assignments.createAssignment(dayId, placeId, null, {
accommodationId,
orderIndex: this.positionForCheckIn(dayId, checkIn),
});
@@ -568,7 +570,7 @@ export class AccommodationsService {
*
* Runs inside the caller's transaction.
*/
private releaseStops(accommodationId: number, opts: { keepStop?: boolean }): AccommodationMirror {
private async releaseStops(accommodationId: number, opts: { keepStop?: boolean }): Promise<AccommodationMirror> {
const mirror = noMirror();
const own = this.ownStops(accommodationId);
const before = this.stopOrders(own.map(stop => stop.day_id));
@@ -578,7 +580,7 @@ export class AccommodationsService {
// The stop stays, but it is the traveller's now. Days hides a stop whose
// accommodation_id is set, so a client left holding the old row keeps the
// place invisible on a day it is standing on.
const released = this.assignments.getAssignmentWithPlace(stop.id);
const released = await this.assignments.getAssignmentWithPlace(stop.id);
if (released) mirror.updated.push(released);
continue;
}
@@ -601,13 +603,13 @@ export class AccommodationsService {
*
* Runs inside the caller's transaction.
*/
private remirrorStay(
private async remirrorStay(
accommodationId: number,
placeId: number | null,
dayId: number,
checkIn?: string | null,
opts: { checkInChanged?: boolean } = {},
): AccommodationMirror {
): Promise<AccommodationMirror> {
const own = this.ownStops(accommodationId);
if (own.length === 0) return noMirror();
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
@@ -629,7 +631,7 @@ export class AccommodationsService {
// on its id, all of which a DELETE takes with it.
if (own.length === 1 && placeId) {
const before = this.stopOrders([own[0].day_id, dayId]);
const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn);
const moved = await this.relocateOwnStop(own[0], placeId, dayId, checkIn);
if (moved) {
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
mirror.stamped = this.stampLodging(placeId);
@@ -644,19 +646,19 @@ export class AccommodationsService {
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
}
this.reanchorVias(mirror, beforeRebuild);
const fresh = this.mirrorStay(accommodationId, placeId, dayId, checkIn);
const fresh = await this.mirrorStay(accommodationId, placeId, dayId, checkIn);
mirror.created = fresh.created;
mirror.stamped = fresh.stamped;
for (const day of fresh.vias ?? []) this.noteVias(mirror, day);
return mirror;
}
createAccommodation(tripId: string | number, data: CreateAccommodationData) {
async createAccommodation(tripId: string | number, data: CreateAccommodationData) {
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
// The stay, its partner hotel reservation and the day stop it implies are one
// logical write, and an atomic one, so a failed insert halfway can't leave an orphan.
const written = this.db.transaction(() => {
const written = await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null
@@ -680,7 +682,7 @@ export class AccommodationsService {
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
);
return { accommodationId: newId, mirror: this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) };
return { accommodationId: newId, mirror: await this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) };
});
return { accommodation: this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror };
@@ -690,7 +692,7 @@ export class AccommodationsService {
return this.db.get<DayAccommodation>('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?', id, tripId);
}
updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
async updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
place_id?: number; start_day_id?: number; end_day_id?: number;
check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string;
}) {
@@ -705,12 +707,12 @@ export class AccommodationsService {
// The stay row and the day stop that mirrors it describe the same booking, so a
// move that wrote only one of the two must not survive.
const mirror = this.db.transaction(() => {
const mirror = await this.uow.transactional(async () => {
this.db.run(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
);
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
return await this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null),
});
});
@@ -742,14 +744,14 @@ export class AccommodationsService {
* that no longer exists. `linkedReservationId` / `deletedBudgetItemId` stay on the
* result as the first of each, because the RPC, MCP and REST callers read them.
*/
deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): {
async deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): Promise<{
linkedReservationId: number | null;
deletedBudgetItemId: number | null;
linkedReservationIds: number[];
deletedBudgetItemIds: number[];
mirror: AccommodationMirror;
} {
return this.db.transaction(() => {
}> {
return this.uow.transactional(async () => {
const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id));
const deletedBudgetItemIds: number[] = [];
for (const res of linkedRes) {
@@ -761,7 +763,7 @@ export class AccommodationsService {
this.db.run('DELETE FROM reservations WHERE id = ?', res.id);
}
const mirror = this.releaseStops(Number(id), opts);
const mirror = await this.releaseStops(Number(id), opts);
this.db.run('DELETE FROM day_accommodations WHERE id = ?', id);
const linkedReservationIds = linkedRes.map(r => r.id);
+3 -3
View File
@@ -27,7 +27,7 @@ export interface HasAddons {
/** Gate an MCP entry on an addon being enabled. */
export function addonGate(addonId: string) {
return (_ctx: McpContext, self: HasAddons): boolean => self.addons.isAddonEnabled(addonId);
return (_ctx: McpContext, self: HasAddons): Promise<boolean> => self.addons.isAddonEnabled(addonId);
}
/**
@@ -35,6 +35,6 @@ export function addonGate(addonId: string) {
* addon toggle and its own flag — notes, polls or chat.
*/
export function collabFeatureGate(feature: 'chat' | 'notes' | 'polls') {
return (_ctx: McpContext, self: HasAddons): boolean =>
self.addons.isAddonEnabled(ADDON_IDS.COLLAB) && self.addons.getCollabFeatures()[feature];
return async (_ctx: McpContext, self: HasAddons): Promise<boolean> =>
(await self.addons.isAddonEnabled(ADDON_IDS.COLLAB)) && (await self.addons.getCollabFeatures())[feature];
}
+2 -2
View File
@@ -19,13 +19,13 @@ export class AddonGuard implements CanActivate {
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<RequireAddonMeta | undefined>(REQUIRE_ADDON, [
context.getHandler(),
context.getClass(),
]);
if (!meta) return true;
if (!this.addons.isAddonEnabled(meta.addonId)) {
if (!(await this.addons.isAddonEnabled(meta.addonId))) {
throw new HttpException({ error: `${meta.label} addon is not enabled` }, 404);
}
return true;
+1 -1
View File
@@ -16,7 +16,7 @@ export class AddonsController {
constructor(private readonly addons: AddonsService) {}
@Get()
list() {
async list() {
return this.addons.list();
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ export class AddonsMcp {
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
})
listAddons(_args: Record<string, never>, _ctx: McpContext) {
const { addons, collabFeatures, bagTracking } = this.addons.list();
async listAddons(_args: Record<string, never>, _ctx: McpContext) {
const { addons, collabFeatures, bagTracking } = await this.addons.list();
// icon, config and fields are admin-panel chrome: an icon name and the
// photo-provider settings form say nothing about what a tool can do.
return ok({
+27 -27
View File
@@ -29,28 +29,28 @@ export class AddonsService {
return this.dbs.connection;
}
isAddonEnabled(addonId: string): boolean {
async isAddonEnabled(addonId: string): Promise<boolean> {
const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as
| { enabled: number }
| undefined;
return !!addon?.enabled;
}
getBagTracking() {
async getBagTracking() {
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value === 'true' };
}
updateBagTracking(enabled: boolean) {
async updateBagTracking(enabled: boolean) {
this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
getCollabFeatures() {
async getCollabFeatures() {
const rows = this.db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
@@ -67,7 +67,7 @@ export class AddonsService {
};
}
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
async updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
@@ -75,12 +75,12 @@ export class AddonsService {
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
const before = this.getCollabFeatures();
const before = await this.getCollabFeatures();
const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
for (const [feat, key] of Object.entries(mapping)) {
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
}
const after = this.getCollabFeatures();
const after = await this.getCollabFeatures();
// Collab flags gate MCP tool/resource registration, so callers must know
// whether anything actually flipped — a no-op save must not tear down every
// live MCP session (#1414).
@@ -88,7 +88,7 @@ export class AddonsService {
return { features: after, changed };
}
list() {
async list() {
const addons = this.db
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
@@ -97,7 +97,7 @@ export class AddonsService {
// here (instead of a migration) also covers installs that still hold an
// enabled provider under a disabled journey from before updateAddon
// cascaded the disable.
const providers = !this.isAddonEnabled(ADDON_IDS.JOURNEY)
const providers = !(await this.isAddonEnabled(ADDON_IDS.JOURNEY))
? []
: (this.db
.prepare(
@@ -135,8 +135,8 @@ export class AddonsService {
}
return {
collabFeatures: this.getCollabFeatures(),
bagTracking: this.getBagTracking().enabled,
collabFeatures: await this.getCollabFeatures(),
bagTracking: (await this.getBagTracking()).enabled,
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
@@ -171,22 +171,22 @@ export class AddonsService {
// backfills 'true' for installs that never touched the switches, so nobody
// loses a feature on upgrade.
private readFlag(key: string) {
private async readFlag(key: string) {
const row = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
return { enabled: row?.value === 'true' };
}
private writeFlag(key: string, enabled: boolean) {
private async writeFlag(key: string, enabled: boolean) {
this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
getPlacesPhotos() { return this.readFlag('places_photos_enabled'); }
updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); }
getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); }
updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); }
getPlacesDetails() { return this.readFlag('places_details_enabled'); }
updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
async getPlacesPhotos() { return this.readFlag('places_photos_enabled'); }
async updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); }
async getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); }
async updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); }
async getPlacesDetails() { return this.readFlag('places_details_enabled'); }
async updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
/**
* The shadow log, fail-CLOSED like the three above but for the opposite
@@ -195,8 +195,8 @@ export class AddonsService {
* so there is nothing to backfill and an absent row correctly means off.
* PlaceShadowService.enabled() reads the same key the same way.
*/
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
async getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
async updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
/**
* Enrichment reads fail-OPEN, unlike the three switches above.
@@ -211,14 +211,14 @@ export class AddonsService {
* the same key the same way. If these two ever disagree the admin panel shows
* "off" while the feature runs, which is worse than either default.
*/
getPlacesEnrich() {
async getPlacesEnrich() {
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); }
async updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); }
// ── Transit backend (#1699) ────────────────────────────────────────────────
// Not a flag: two named backends, so it stores the name rather than a
@@ -243,11 +243,11 @@ export class AddonsService {
return resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey).source;
}
getTransitProvider(userId = 0) {
return { provider: readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) };
async getTransitProvider(userId = 0) {
return { provider: await readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) };
}
updateTransitProvider(provider: TransitProvider, userId = 0) {
return { provider: writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) };
async updateTransitProvider(provider: TransitProvider, userId = 0) {
return { provider: await writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) };
}
}
+10 -5
View File
@@ -9,11 +9,16 @@ import { ADDON_IDS } from '../../addons';
* hand the bare RequestHandler to consumer.apply() ahead of the SDK routers.
*/
export function createMcpAddonGate(addons: AddonsService): RequestHandler {
// Express cannot await a middleware, so the now-async gate runs in a helper and
// its rejection is handed to next() — the same error path a synchronous throw
// took before (recipe R1.5).
return (_req, res, next) => {
if (!addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(404).end();
return;
}
next();
void (async () => {
if (!(await addons.isAddonEnabled(ADDON_IDS.MCP))) {
res.status(404).end();
return;
}
next();
})().catch(next);
};
}
+51 -51
View File
@@ -79,39 +79,39 @@ export class AdminController {
@Post('users')
@HttpCode(201)
createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = ok(this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
}
@Delete('users/:id')
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deleteUser(id, user.id));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
}
@Delete('users/:id/passkeys')
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserPasskeys(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
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 };
}
@Delete('users/:id/mfa')
resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserMfa(id, user.id));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
return { success: true };
}
@@ -123,9 +123,9 @@ export class AdminController {
permissions() { return this.admin.getPermissions(); }
@Put('permissions')
savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
async savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
const result = await this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
await this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
}
@@ -134,12 +134,12 @@ export class AdminController {
@Post('save-demo-baseline')
@HttpCode(200)
saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.saveDemoBaseline();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
@@ -171,16 +171,16 @@ export class AdminController {
@Post('invites')
@HttpCode(201)
createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
const result = this.invites.createInvite(user.id, body);
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 } });
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')
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.invites.deleteInvite(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -197,9 +197,9 @@ export class AdminController {
getBagTracking() { return this.addons.getBagTracking(); }
@Put('bag-tracking')
updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updateBagTracking(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
async updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateBagTracking(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -207,9 +207,9 @@ export class AdminController {
getPlacesPhotos() { return this.addons.getPlacesPhotos(); }
@Put('places-photos')
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesPhotos(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
async updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesPhotos(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -217,9 +217,9 @@ export class AdminController {
getPlacesAutocomplete() { return this.addons.getPlacesAutocomplete(); }
@Put('places-autocomplete')
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesAutocomplete(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
async updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesAutocomplete(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -227,9 +227,9 @@ export class AdminController {
getPlacesDetails() { return this.addons.getPlacesDetails(); }
@Put('places-details')
updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesDetails(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
async updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesDetails(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -237,9 +237,9 @@ export class AdminController {
getPlaceShadow() { return this.addons.getPlaceShadow(); }
@Put('place-shadow')
updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlaceShadow(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlaceShadow(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -247,9 +247,9 @@ export class AdminController {
getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
@Put('places-enrich')
updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesEnrich(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } });
async updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlacesEnrich(body.enabled);
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@@ -257,12 +257,12 @@ export class AdminController {
getCollabFeatures() { return this.addons.getCollabFeatures(); }
@Put('collab-features')
updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
const { features, changed } = this.addons.updateCollabFeatures(body);
async updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
const { features, changed } = await this.addons.updateCollabFeatures(body);
// Collab flags gate MCP registration, but a no-op save must not tear down
// every live MCP session (#1414).
if (changed) this.admin.invalidateMcpSessions();
this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
await this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
return features;
}
@@ -272,9 +272,9 @@ export class AdminController {
getTransitProvider(@CurrentUser() user: User) { return this.addons.getTransitProvider(user.id); }
@Put('transit-provider')
updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
const result = this.addons.updateTransitProvider(body.provider, user.id);
this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } });
async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
const result = await this.addons.updateTransitProvider(body.provider, user.id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } });
return result;
}
@@ -284,8 +284,8 @@ export class AdminController {
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateAddon(id, body));
this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
const result = await ok(await this.admin.updateAddon(id, body));
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
// Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
// saves and photo-provider toggles used to kill every session (#1414).
@@ -309,9 +309,9 @@ export class AdminController {
listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; }
@Delete('mcp-tokens/:id')
deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.tokens.adminDeleteMcpToken(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -319,9 +319,9 @@ export class AdminController {
listOAuthSessions() { return { sessions: this.oauth.listAllOAuthSessions() }; }
@Delete('oauth-sessions/:id')
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.oauth.adminRevokeOAuthSession(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -329,12 +329,12 @@ export class AdminController {
@ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach')
@Post('rotate-jwt-secret')
@HttpCode(200)
rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.rotateJwtSecret();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
+8 -8
View File
@@ -309,8 +309,8 @@ export class AdminService {
// ── Permissions ────────────────────────────────────────────────────────────
getPermissions() {
const current = this.permissions.getAllPermissions();
async getPermissions() {
const current = await this.permissions.getAllPermissions();
const actions = PERMISSION_ACTIONS.map((a) => ({
key: a.key,
level: current[a.key],
@@ -320,9 +320,9 @@ export class AdminService {
return { permissions: actions };
}
savePermissions(permissions: Record<string, string>) {
const { skipped } = this.permissions.savePermissions(permissions);
return { permissions: this.permissions.getAllPermissions(), skipped };
async savePermissions(permissions: Record<string, string>) {
const { skipped } = await this.permissions.savePermissions(permissions);
return { permissions: await this.permissions.getAllPermissions(), skipped };
}
// ── Audit Log ──────────────────────────────────────────────────────────────
@@ -664,7 +664,7 @@ export class AdminService {
];
}
updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
async updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
@@ -682,14 +682,14 @@ export class AdminService {
// Photo providers are Journey's shelf rows — their whole UI lives inside
// journeys, so enabling one under a disabled journey addon would only
// advertise an integration nothing can reach.
if (provider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) {
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
return { error: 'Enable the Journey addon first', status: 409 };
}
// Same rule one shelf down: a document provider only exists to serve the
// file manager, so switching one on under a disabled Documents addon would
// advertise a sync nothing can reach.
if (docProvider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) {
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
return { error: 'Enable the Documents addon first', status: 409 };
}
+3 -3
View File
@@ -16,9 +16,9 @@ import { DatabaseService } from '../database/database.service';
export class AirportsService implements OnApplicationBootstrap {
constructor(private readonly db: DatabaseService) {}
onApplicationBootstrap(): void {
async onApplicationBootstrap(): Promise<void> {
try {
this.backfillFlightEndpoints();
await this.backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
@@ -32,7 +32,7 @@ export class AirportsService implements OnApplicationBootstrap {
return findByIata(code) as Airport | null;
}
backfillFlightEndpoints(): void {
async backfillFlightEndpoints(): Promise<void> {
const pending = this.db.prepare(`
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
FROM reservations r
@@ -48,16 +48,16 @@ export class DayAssignmentsController {
constructor(private readonly assignments: AssignmentsService) {}
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
async list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
return { assignments: this.assignments.listDayAssignments(dayId) };
return { assignments: await this.assignments.listDayAssignments(dayId) };
}
@RequirePermission('day_edit')
@Post()
create(
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@@ -70,7 +70,7 @@ export class DayAssignmentsController {
if (!this.assignments.placeExists(body.place_id, tripId)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes);
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
this.assignments.reconcile(tripId, socketId);
return { assignment };
@@ -128,7 +128,7 @@ export class AssignmentOpsController {
@RequirePermission('day_edit')
@Put(':id/move')
move(
async move(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -141,7 +141,7 @@ export class AssignmentOpsController {
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
throw new HttpException({ error: 'Target day not found' }, 404);
}
const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
const { assignment, oldDayId } = await this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
this.assignments.reconcile(tripId, socketId);
return { assignment };
@@ -157,7 +157,7 @@ export class AssignmentOpsController {
@RequirePermission('day_edit')
@Put(':id/time')
time(
async time(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -167,7 +167,7 @@ export class AssignmentOpsController {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const { assignment, reordered, vias } = this.assignments.updateTime(id, body.place_time, body.end_time);
const { assignment, reordered, vias } = await this.assignments.updateTime(id, body.place_time, body.end_time);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
// The whole day when a start moved stops, or collaborators apply the one row
// they were sent to their old order and end up with a third one.
@@ -184,7 +184,7 @@ export class AssignmentOpsController {
@RequirePermission('day_edit')
@Put(':id/end-day')
endDay(
async endDay(
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: AssignmentEndDayDto,
@@ -193,7 +193,7 @@ export class AssignmentOpsController {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.setEndDay(id, body.end_day);
const assignment = await this.assignments.setEndDay(id, body.end_day);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@@ -203,7 +203,7 @@ export class AssignmentOpsController {
// neighbours; no reconcile — the note doesn't touch the journey skeleton.
@RequirePermission('day_edit')
@Put(':id/notes')
notes(
async notes(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -213,14 +213,14 @@ export class AssignmentOpsController {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.updateNotes(id, body.notes);
const assignment = await this.assignments.updateNotes(id, body.notes);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@RequirePermission('day_edit')
@Put(':id/transport')
transport(
async transport(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -231,8 +231,8 @@ export class AssignmentOpsController {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = body.direction === 'incoming'
? this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null)
: this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
? await this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null)
: await this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
+16 -16
View File
@@ -48,10 +48,10 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.');
if (!this.assignments.placeExists(placeId, tripId)) return errorResult('Place not found.');
const assignment = this.assignments.createAssignment(dayId, placeId, notes || null);
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null);
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment });
this.assignments.reconcile(tripId);
return ok({ assignment });
@@ -74,9 +74,9 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = this.assignments.setEndDay(assignmentId, end_day);
const assignment = await this.assignments.setEndDay(assignmentId, end_day);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@@ -98,7 +98,7 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId))
return errorResult('Assignment not found.');
this.assignments.deleteAssignment(assignmentId);
@@ -127,10 +127,10 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) return errorResult('Assignment not found.');
const { assignment, reordered, vias } = this.assignments.updateTime(
const { assignment, reordered, vias } = await this.assignments.updateTime(
assignmentId,
place_time !== undefined ? place_time : existing.assignment_time,
end_time !== undefined ? end_time : existing.assignment_end_time
@@ -163,9 +163,9 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = this.assignments.updateNotes(assignmentId, notes);
const assignment = await this.assignments.updateNotes(assignmentId, notes);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@@ -190,11 +190,11 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = direction === 'incoming'
? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
: this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
? await this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
: await this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@@ -220,10 +220,10 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
if (!this.days.getDay(newDayId, tripId)) return errorResult('Day not found.');
const result = this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
const result = await this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
// REST parity shape ({ assignment, oldDayId, newDayId }) — the client keys its
// per-day assignment map on newDayId, so omitting it filed the moved assignment
// under "undefined" on collaborator screens.
@@ -269,7 +269,7 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const participants = this.assignments.setParticipants(assignmentId, userIds, tripId);
this.guards.safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
@@ -293,7 +293,7 @@ export class AssignmentsMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
this.assignments.reorderAssignments(dayId, assignmentIds);
// REST parity shape ({ dayId, orderedIds }) — the client only reads orderedIds,
@@ -25,7 +25,7 @@ interface DayStopRow {
* vias; both stay null when the save left every stop where it was.
*/
export interface AssignmentTimeUpdate {
assignment: ReturnType<AssignmentsService['getAssignmentWithPlace']>;
assignment: Awaited<ReturnType<AssignmentsService['getAssignmentWithPlace']>>;
reordered: { dayId: number; orderedIds: number[] } | null;
vias: { dayId: number; vias: RoadtripVia[] } | null;
}
@@ -70,7 +70,7 @@ export class AssignmentsService {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -92,7 +92,7 @@ export class AssignmentsService {
* Public because the accommodation mirror moves a stop in place and has to hand
* the moved row back in exactly this shape.
*/
getAssignmentWithPlace(assignmentId: number | bigint) {
async getAssignmentWithPlace(assignmentId: number | bigint) {
const a = this.dbs.get<AssignmentRow>(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -111,7 +111,7 @@ export class AssignmentsService {
// Same compact tag projection as listDayAssignments, so an assignment has
// one wire shape regardless of which read path produced it.
const tags = this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true })[a.place_id] || [];
const tags = (await this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true }))[a.place_id] || [];
const participants = this.dbs.all<Participant>(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
@@ -128,7 +128,7 @@ export class AssignmentsService {
return formatAssignmentWithPlace(a, tags, participants);
}
listDayAssignments(dayId: string | number) {
async listDayAssignments(dayId: string | number) {
const assignments = this.dbs.all<AssignmentRow>(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -145,10 +145,10 @@ export class AssignmentsService {
`, dayId);
const placeIds = [...new Set(assignments.map(a => a.place_id))];
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const assignmentIds = assignments.map(a => a.id);
const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds);
const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds);
return assignments.map(a => {
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
@@ -219,7 +219,7 @@ export class AssignmentsService {
`, id, tripId);
}
moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
async moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
// The source day comes from the row, not the caller — callers can't lie
// about (or race on) where the assignment was.
const oldDayId = this.dbs.transaction(() => {
@@ -227,7 +227,7 @@ export class AssignmentsService {
this.dbs.run('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?', newDayId, orderIndex ?? 0, id);
return row?.day_id;
});
const updated = this.getAssignmentWithPlace(Number(id));
const updated = await this.getAssignmentWithPlace(Number(id));
return { assignment: updated, oldDayId };
}
@@ -253,7 +253,7 @@ export class AssignmentsService {
* such a day the order stored and the order drawn can differ. The old sort did the
* same.
*/
updateTime(id: string | number, placeTime: unknown, endTime: unknown): AssignmentTimeUpdate {
async updateTime(id: string | number, placeTime: unknown, endTime: unknown): Promise<AssignmentTimeUpdate> {
const sorted = this.dbs.transaction(() => {
const stored = this.dbs.get<{ day_id: number; start: string | null }>(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
@@ -280,7 +280,7 @@ export class AssignmentsService {
});
return {
assignment: this.getAssignmentWithPlace(Number(id)),
assignment: await this.getAssignmentWithPlace(Number(id)),
reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null,
vias: sorted?.viasMoved ? { dayId: sorted.dayId, vias: this.listDayVias(sorted.dayId) } : null,
};
+5 -5
View File
@@ -25,27 +25,27 @@ export class ItineraryRpc {
) {}
@PluginMethod('itinerary.assign', { permission: 'db:write:itinerary' })
assign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async assign(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'itinerary');
const notes = params.notes === undefined || params.notes === null ? null : str(params.notes, 'notes');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
if (!this.assignments.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
if (!this.assignments.placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
const assignment = this.assignments.createAssignment(dayId, placeId, notes);
const assignment = await this.assignments.createAssignment(dayId, placeId, notes);
this.realtime.broadcast(tripId, 'assignment:created', { assignment });
this.assignments.reconcile(tripId);
return assignment;
}
@PluginMethod('itinerary.unassign', { permission: 'db:write:itinerary' })
unassign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async unassign(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const assignmentId = num(params.assignmentId, 'assignmentId');
const actor = this.guards.requireActor(ctx, 'itinerary');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
this.assignments.deleteAssignment(assignmentId);
+18 -18
View File
@@ -23,9 +23,9 @@ export class AtlasRpc {
) {}
@PluginMethod('atlas.visited', { permission: 'db:read:atlas' })
visited(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async visited(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'reads');
this.requireAtlasAddon();
await this.requireAtlasAddon();
return {
countries: this.atlas.listVisitedCountries(userId),
regions: this.atlas.listManuallyVisitedRegions(userId),
@@ -33,32 +33,32 @@ export class AtlasRpc {
}
@PluginMethod('atlas.bucketList', { permission: 'db:read:atlas' })
bucketList(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async bucketList(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'reads');
this.requireAtlasAddon();
await this.requireAtlasAddon();
return this.atlas.bucketList(userId) as unknown[];
}
@PluginMethod('atlas.markCountry', { permission: 'db:write:atlas' })
markCountry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async markCountry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
const code = this.code(params.code, 'code');
this.requireAtlasAddon();
await this.requireAtlasAddon();
this.atlas.markCountry(userId, code);
return { visited: true };
}
@PluginMethod('atlas.unmarkCountry', { permission: 'db:write:atlas' })
unmarkCountry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async unmarkCountry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
const code = this.code(params.code, 'code');
this.requireAtlasAddon();
await this.requireAtlasAddon();
this.atlas.unmarkCountry(userId, code);
return { visited: false };
}
@PluginMethod('atlas.markRegion', { permission: 'db:write:atlas' })
markRegion(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async markRegion(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
// A missing name falls back to the code, so the row is never nameless.
const regionName = typeof params.regionName === 'string' && params.regionName
@@ -66,26 +66,26 @@ export class AtlasRpc {
: String(params.regionCode ?? '');
const regionCode = this.code(params.regionCode, 'regionCode');
const countryCode = this.code(params.countryCode, 'countryCode');
this.requireAtlasAddon();
await this.requireAtlasAddon();
this.atlas.markRegion(userId, regionCode, regionName, countryCode);
return { visited: true };
}
@PluginMethod('atlas.unmarkRegion', { permission: 'db:write:atlas' })
unmarkRegion(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async unmarkRegion(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
const regionCode = this.code(params.regionCode, 'regionCode');
this.requireAtlasAddon();
await this.requireAtlasAddon();
this.atlas.unmarkRegion(userId, regionCode);
return { visited: false };
}
@PluginMethod('atlas.createBucketItem', { permission: 'db:write:atlas' })
createBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
this.requireAtlasAddon();
await this.requireAtlasAddon();
try {
return this.atlas.createBucketItem(userId, input as never);
} catch (err) {
@@ -97,10 +97,10 @@ export class AtlasRpc {
}
@PluginMethod('atlas.deleteBucketItem', { permission: 'db:write:atlas' })
deleteBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async deleteBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireAtlasUser(ctx, 'writes');
const itemId = num(params.itemId, 'itemId');
this.requireAtlasAddon();
await this.requireAtlasAddon();
if (!this.atlas.deleteBucketItem(userId, itemId)) {
throw new ForbiddenResource(`no bucket item ${itemId} for this user`);
}
@@ -121,8 +121,8 @@ export class AtlasRpc {
return ctx.actingUserId;
}
private requireAtlasAddon(): void {
this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas');
private async requireAtlasAddon(): Promise<void> {
await this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas');
}
/** Country and region codes are short and stored upper-cased. */
+4 -4
View File
@@ -68,7 +68,7 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
export class AuditService {
constructor(private readonly dbs: DatabaseService) {}
private resolveUserEmail(userId: number | null): string {
private async resolveUserEmail(userId: number | null): Promise<string> {
if (userId == null) return 'anonymous';
try {
const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
@@ -77,14 +77,14 @@ export class AuditService {
}
/** Best-effort; never throws — failures are logged only. */
writeAudit(entry: {
async writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
debugDetails?: Record<string, unknown>;
ip?: string | null;
}): void {
}): Promise<void> {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
this.dbs.run(
@@ -92,7 +92,7 @@ export class AuditService {
entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null
);
const email = this.resolveUserEmail(entry.userId);
const email = await this.resolveUserEmail(entry.userId);
const label = ACTION_LABELS[entry.action] || entry.action;
const brief = buildInfoSummary(entry.action, entry.details);
logInfo(oneLine(`${email} ${label}${brief} ip=${entry.ip || '-'}`));
+13 -13
View File
@@ -68,13 +68,13 @@ export class AuthPublicController {
@Post('register')
@Public('creating the account that would carry the session')
@HttpCode(201)
register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
const result = this.auth.registerUser(body);
const result = await this.auth.registerUser(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
this.auth.setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
@@ -87,7 +87,7 @@ export class AuthPublicController {
const started = Date.now();
const result = this.auth.loginUser(body);
if (result.auditAction) {
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
@@ -120,15 +120,15 @@ export class AuthPublicController {
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
const origin = this.auth.getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch {
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed);
@@ -138,33 +138,33 @@ export class AuthPublicController {
@Post('reset-password')
@Public('the reset token IS the credential')
@HttpCode(200)
resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) {
async resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) {
// Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on
// 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);
if (result.error) {
this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: 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!);
}
if (result.mfa_required) {
return { mfa_required: true };
}
this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
await this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
return { success: true };
}
@Post('mfa/verify-login')
@Public('second factor of a login that has no session yet')
@HttpCode(200)
verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('mfa', req, 5);
const result = this.auth.verifyMfaLogin(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
}
+18 -18
View File
@@ -107,7 +107,7 @@ export class AuthController {
}
@Put('me/password')
changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
async changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 5);
// Carry the session's remember choice into the re-issued token/cookie so a
// "remember me" login survives a password change (#1927). Bearer callers
@@ -120,17 +120,17 @@ export class AuthController {
// Refresh this device's cookie with the new password_version so the user
// stays logged in here while all other sessions are invalidated.
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
return { success: true };
}
@Delete('me')
deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
const result = this.auth.deleteAccount(user.id, user.email, user.role);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
return { success: true };
}
@@ -145,9 +145,9 @@ export class AuthController {
* Nothing is written when nothing changed — the panel saves before every test
* click, and each click would otherwise cost a log line.
*/
private auditApiKeys(userId: number, changed: string[], req: Request): void {
private async auditApiKeys(userId: number, changed: string[], req: Request): Promise<void> {
if (!changed.length) return;
this.audit.writeAudit({
await this.audit.writeAudit({
userId,
action: 'settings.api_keys_update',
resource: 'api_keys',
@@ -157,28 +157,28 @@ export class AuthController {
}
@Put('me/maps-key')
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
async mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
// changedKeys is for the audit line, not for the client: destructured off so
// the response body stays what it always was.
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
this.auditApiKeys(user.id, changedKeys, req);
await this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/api-keys')
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
async apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
this.auditApiKeys(user.id, changedKeys, req);
await this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/settings')
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
@@ -239,12 +239,12 @@ export class AuthController {
@Put('app-settings')
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
const result = this.auth.updateAppSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
// Named so the settings tab can say which fields the operator holds rather
// than showing a saved value that silently did not save.
return { success: true, ...(result.managedKeys?.length ? { managed_keys: result.managedKeys } : {}) };
@@ -274,25 +274,25 @@ export class AuthController {
@Post('mfa/enable')
@MfaExempt('completing setup is the way out of the policy')
@HttpCode(200)
mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
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);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
}
@Post('mfa/disable')
@HttpCode(200)
mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
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);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled };
}
+7 -5
View File
@@ -10,6 +10,7 @@ import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
@@ -132,6 +133,7 @@ export class AuthService {
private readonly mailer: MailerService,
private readonly tokens: EphemeralTokenService,
private readonly allowedFileTypes: AllowedFileTypesService,
private readonly uow: UnitOfWork,
) {}
// Cookie
@@ -253,7 +255,7 @@ export class AuthService {
return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw);
}
getAppConfig(authenticatedUser: User | undefined | null) {
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();
@@ -350,7 +352,7 @@ export class AuthService {
places_details_enabled: placesDetailsEnabled,
places_enrich_enabled: placesEnrichEnabled,
place_shadow_enabled: placeShadowEnabled,
permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined,
permissions: authenticatedUser ? (await this.permissions.getAllPermissions()) : undefined,
// Case-sensitive on purpose (legacy parity).
dev_mode: readEnv().app.nodeEnv === 'development',
};
@@ -379,7 +381,7 @@ export class AuthService {
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
}
registerUser(rawBody: unknown): { error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> } {
async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
const username = typeof body.username === 'string' ? body.username.trim() : '';
const email = typeof body.email === 'string' ? body.email.trim() : '';
@@ -426,7 +428,7 @@ export class AuthService {
try {
// One transaction for the whole signup: a mid-sequence throw (invite
// bookkeeping, trip auto-join) must not leave a half-registered user.
return this.db.transaction(() => {
return await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)',
username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'
@@ -446,7 +448,7 @@ export class AuthService {
// Trip-bound invite (#1402): auto-add the freshly registered user to the
// trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted.
if (validInvite.trip_id) {
this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
}
}
+5 -5
View File
@@ -63,7 +63,7 @@ export class PasskeyController {
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
return { success: true, credential: result.credential };
}
@@ -88,14 +88,14 @@ export class PasskeyController {
const started = Date.now();
const result = await this.passkeys.passkeyLoginVerify(body);
if (result.auditAction) {
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
}
// Pad to the same floor as password login so timing can't distinguish a
// known credential from an unknown one.
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
@@ -118,11 +118,11 @@ export class PasskeyController {
@Delete('credentials/:id')
@UseGuards(JwtAuthGuard)
remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
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);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
}
+6 -6
View File
@@ -67,7 +67,7 @@ export class BackupController {
}
try {
const backup = await this.backup.createBackup();
this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
await this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
return { success: true, backup };
} catch {
throw new HttpException({ error: 'Error creating backup' }, 500);
@@ -109,7 +109,7 @@ export class BackupController {
if (!result.success) {
throw new HttpException({ error: result.error }, result.status || 400);
}
this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
return { success: true };
} catch (err) {
if (err instanceof HttpException) throw err;
@@ -142,7 +142,7 @@ export class BackupController {
if (!result.success) {
throw new HttpException({ error: result.error }, result.status || 400);
}
this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
return { success: true };
} catch (err) {
if (err instanceof HttpException) throw err;
@@ -164,10 +164,10 @@ export class BackupController {
@ManagedForbidden('the operator schedules backups off-volume; a second schedule inside it is not one')
@Put('auto-settings')
updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) {
async updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) {
try {
const settings = this.autoBackup.updateAutoSettings(body || {});
this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
await this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
return { settings };
} catch (err) {
console.error('[backup] PUT auto-settings:', err);
@@ -185,7 +185,7 @@ export class BackupController {
throw new HttpException({ error: 'Backup not found' }, 404);
}
await this.backup.deleteBackup(filename);
this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
await this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
return { success: true };
}
}
@@ -52,7 +52,7 @@ export class BookingImportService {
}
/** True when the LLM fallback is enabled and configured for this user. */
aiAvailable(userId: number): boolean {
async aiAvailable(userId: number): Promise<boolean> {
return this.llmParse.isAvailable(userId);
}
@@ -137,7 +137,7 @@ export class BookingImportService {
onProgress?: (done: number, total: number, fileName: string) => void,
): Promise<BookingImportPreviewResponse> {
const kitineraryAvailable = this.extractor.isAvailable();
const aiAvailable = this.llmParse.isAvailable(userId);
const aiAvailable = await this.llmParse.isAvailable(userId);
if (!kitineraryAvailable && !aiAvailable) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
@@ -296,7 +296,7 @@ export class BookingImportService {
};
}
const { reservation, accommodationCreated } = this.reservations.create(tripId, {
const { reservation, accommodationCreated } = await this.reservations.create(tripId, {
...reservationData,
place_id: placeId,
create_accommodation: createAccommodation,
@@ -309,7 +309,7 @@ export class BookingImportService {
// Turn an extracted price into a real linked cost (Costs addon), so the
// booking shows up as an expense — not just a price in metadata.
if (this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) {
if ((await this.addons.isAddonEnabled(ADDON_IDS.BUDGET))) {
const meta =
reservationData.metadata && typeof reservationData.metadata === 'object'
? (reservationData.metadata as Record<string, unknown>)
+14 -14
View File
@@ -99,11 +99,11 @@ export class BudgetMcp {
* and is passed through. Reads ride the leaf TripMembershipService — the
* hydrated member services all live in modules that import this one.
*/
private resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
private async resolveMemberIds(tripId: number, member_ids?: number[]): Promise<number[] | undefined> {
if (member_ids !== undefined) return member_ids;
const ownerId = this.membership.getOwnerId(tripId);
const ownerId = await this.membership.getOwnerId(tripId);
if (ownerId === null) return undefined;
return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)]));
return Array.from(new Set([ownerId, ...(await this.membership.listMemberUserIds(tripId))]));
}
/**
@@ -205,7 +205,7 @@ export class BudgetMcp {
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
if (members !== undefined) {
@@ -214,7 +214,7 @@ export class BudgetMcp {
}
// The split participants are the members of an uneven split; the equal-split
// list still carries them so the row's `persons` count comes out the same.
const splitIds = members ? members.map(m => m.user_id) : this.resolveMemberIds(tripId, member_ids);
const splitIds = members ? members.map(m => m.user_id) : await this.resolveMemberIds(tripId, member_ids);
const itemData = { category, name, total_price, currency, member_ids: splitIds, members, payers, expense_date, place_id, note };
// Freeze the live FX rate at entry time so a settled position isn't re-opened
// when live rates drift (#1445) — same as the REST create path.
@@ -238,7 +238,7 @@ export class BudgetMcp {
async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.budget.deleteBudgetItem(itemId, tripId);
if (!deleted) return errorResult('Budget item not found.');
this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId });
@@ -280,7 +280,7 @@ export class BudgetMcp {
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
if (members !== undefined) {
// An edit that leaves the total alone still has to reconcile against it, so
@@ -324,10 +324,10 @@ export class BudgetMcp {
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
// Omitted userIds → default to the whole trip, matching create_budget_item.
const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined);
const members = (userIds && userIds.length > 0) ? userIds : await this.resolveMemberIds(tripId, undefined);
try {
const item = this.db.transaction(() => {
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members, place_id });
@@ -356,7 +356,7 @@ export class BudgetMcp {
async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.budget.updateMembers(itemId, tripId, userIds);
if (!result) return errorResult('Budget item not found.');
const item = this.budget.getBudgetItem(itemId, tripId);
@@ -380,7 +380,7 @@ export class BudgetMcp {
async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid);
this.guards.safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, userId: memberId, paid: paid ? 1 : 0 });
return ok({ member });
@@ -445,7 +445,7 @@ export class BudgetMcp {
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
// Freeze-then-write composite, same as the REST path: the rate for the display
// currency is frozen at entry time (#1445).
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId);
@@ -476,7 +476,7 @@ export class BudgetMcp {
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
// Freeze-then-write composite, same as the REST path: an edit that leaves the
// currency alone keeps the rate frozen at settle time.
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at });
@@ -499,7 +499,7 @@ export class BudgetMcp {
async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.budget.deleteSettlement(settlementId, tripId);
if (!deleted) return errorResult('Settlement not found.');
this.guards.safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
+1 -1
View File
@@ -118,7 +118,7 @@ export class BudgetService {
return this.db.canAccessTrip(tripId, userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
+16 -16
View File
@@ -38,22 +38,22 @@ export class CostsRpc {
@PluginMethod('costs.getByTrip', { permission: 'db:read:costs' })
getByTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () => {
this.requireBudgetAddon();
return this.guards.tripRead(params, ctx, async () => {
await this.requireBudgetAddon();
return this.budget.listBudgetItems(num(params.tripId, 'tripId'));
});
}
@PluginMethod('costs.listMine', { permission: 'db:read:costs' })
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// Cross-trip aggregate. The acting user is host-bound; a job or onLoad is refused
// the same way tripRead refuses one.
if (ctx.actingUserId === undefined) throw new ForbiddenResource('cost reads require an authenticated user context');
this.requireBudgetAddon();
await this.requireBudgetAddon();
// The leaf membership read, not TripsService.list: TripsModule imports this
// one, so injecting TripsService here would close a cycle. Same id set,
// same newest-first order.
const tripIds = this.membership.listAccessibleTripIds(ctx.actingUserId);
const tripIds = await this.membership.listAccessibleTripIds(ctx.actingUserId);
return tripIds.flatMap((id) => this.budget.listBudgetItems(id));
}
@@ -61,10 +61,10 @@ export class CostsRpc {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.requireCostActor(ctx);
this.requireBudgetAddon();
await this.requireBudgetAddon();
const parsed = budgetCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
this.requireCostEdit(tripId, actor);
await this.requireCostEdit(tripId, actor);
// BudgetService.create freezes the FX rate and resolves members/payers, so the
// plugin path produces the same row the web app would.
const item = await this.budget.create(String(tripId), parsed.data);
@@ -77,10 +77,10 @@ export class CostsRpc {
const tripId = num(params.tripId, 'tripId');
const itemId = num(params.itemId, 'itemId');
const actor = this.requireCostActor(ctx);
this.requireBudgetAddon();
await this.requireBudgetAddon();
const parsed = budgetUpdateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
this.requireCostEdit(tripId, actor);
await this.requireCostEdit(tripId, actor);
// update re-freezes the FX rate on a currency change, exactly like create.
const item = await this.budget.update(String(itemId), String(tripId), parsed.data);
if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
@@ -89,12 +89,12 @@ export class CostsRpc {
}
@PluginMethod('costs.delete', { permission: 'db:write:costs' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const itemId = num(params.itemId, 'itemId');
const actor = this.requireCostActor(ctx);
this.requireBudgetAddon();
this.requireCostEdit(tripId, actor);
await this.requireBudgetAddon();
await this.requireCostEdit(tripId, actor);
if (!this.budget.remove(String(itemId), String(tripId))) {
throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
}
@@ -110,14 +110,14 @@ export class CostsRpc {
return ctx.actingUserId;
}
private requireBudgetAddon(): void {
this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs');
private async requireBudgetAddon(): Promise<void> {
await this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs');
}
/** Trip access plus budget_edit, with the cost-specific refusal message. */
private requireCostEdit(tripId: number, userId: number): void {
private async requireCostEdit(tripId: number, userId: number): Promise<void> {
if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId)) {
if (!(await this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId))) {
throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
}
}
@@ -28,32 +28,32 @@ export class CategoriesController {
@Get()
@UseGuards(JwtAuthGuard)
list(): CategoryListResponse {
return { categories: this.categories.list() };
async list(): Promise<CategoryListResponse> {
return { categories: await this.categories.list() };
}
@Post()
@UseGuards(JwtAuthGuard, AdminGuard)
create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): { category: Category } {
return { category: this.categories.create(user.id, body.name, body.color, body.icon) };
async create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): Promise<{ category: Category }> {
return { category: await this.categories.create(user.id, body.name, body.color, body.icon) };
}
@Put(':id')
@UseGuards(JwtAuthGuard, AdminGuard)
update(@Param('id') id: string, @Body() body: CategoryUpdateDto): { category: Category } {
if (!this.categories.getById(id)) {
async update(@Param('id') id: string, @Body() body: CategoryUpdateDto): Promise<{ category: Category }> {
if (!(await this.categories.getById(id))) {
throw new HttpException({ error: 'Category not found' }, 404);
}
return { category: this.categories.update(id, body.name, body.color, body.icon) };
return { category: await this.categories.update(id, body.name, body.color, body.icon) };
}
@Delete(':id')
@UseGuards(JwtAuthGuard, AdminGuard)
remove(@Param('id') id: string): { success: boolean } {
if (!this.categories.getById(id)) {
async remove(@Param('id') id: string): Promise<{ success: boolean }> {
if (!(await this.categories.getById(id))) {
throw new HttpException({ error: 'Category not found' }, 404);
}
this.categories.remove(id);
await this.categories.remove(id);
return { success: true };
}
}
+7 -7
View File
@@ -51,7 +51,7 @@ export class CategoriesMcp {
access: { group: 'places', mode: 'read' },
})
async listCategories(_args: Record<string, never>, _ctx: McpContext) {
const categories = this.categories.list();
const categories = await this.categories.list();
return ok({ categories });
}
@@ -70,7 +70,7 @@ export class CategoriesMcp {
if (this.isDemoUser(ctx.userId)) return demoDenied();
// The palette is instance-wide; the REST route restricts management to admins. Match it.
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
const category = this.categories.create(ctx.userId, name, color, icon);
const category = await this.categories.create(ctx.userId, name, color, icon);
return ok({ category });
}
@@ -89,8 +89,8 @@ export class CategoriesMcp {
async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
const category = this.categories.update(categoryId, name, color, icon);
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
const category = await this.categories.update(categoryId, name, color, icon);
return ok({ category });
}
@@ -106,8 +106,8 @@ export class CategoriesMcp {
async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
this.categories.remove(categoryId);
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
await this.categories.remove(categoryId);
return ok({ success: true });
}
@@ -118,7 +118,7 @@ export class CategoriesMcp {
mimeType: 'application/json',
})
async categoriesResource(uri: URL, _ctx: McpContext) {
const categories = this.categories.list();
const categories = await this.categories.list();
return {
contents: [{
uri: uri.href,
+2 -2
View File
@@ -11,7 +11,7 @@ export class CategoriesRpc {
constructor(private readonly categories: CategoriesService) {}
@PluginMethod('categories.list', { permission: 'db:read:categories' })
list(): unknown[] {
return this.categories.list() as unknown[];
async list(): Promise<unknown[]> {
return (await this.categories.list()) as unknown[];
}
}
@@ -12,15 +12,15 @@ import { DatabaseService } from '../database/database.service';
export class CategoriesService {
constructor(private readonly db: DatabaseService) {}
list(): Category[] {
async list(): Promise<Category[]> {
return this.db.all<Category>('SELECT * FROM categories ORDER BY name ASC');
}
getById(id: string | number): Category | undefined {
async getById(id: string | number): Promise<Category | undefined> {
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id);
}
create(userId: number, name: string, color?: string, icon?: string): Category {
async create(userId: number, name: string, color?: string, icon?: string): Promise<Category> {
const result = this.db.run(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)',
name,
@@ -34,7 +34,7 @@ export class CategoriesService {
) as Category;
}
update(id: string | number, name?: string, color?: string, icon?: string): Category {
async update(id: string | number, name?: string, color?: string, icon?: string): Promise<Category> {
this.db.run(
`
UPDATE categories SET
@@ -51,7 +51,7 @@ export class CategoriesService {
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id) as Category;
}
remove(id: string | number): void {
async remove(id: string | number): Promise<void> {
this.db.run('DELETE FROM categories WHERE id = ?', id);
}
}
+5 -5
View File
@@ -118,8 +118,8 @@ export class CollabController {
return trip;
}
private requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): void {
if (!this.collab.canEdit(trip, user)) {
private async requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): Promise<void> {
if (!(await this.collab.canEdit(trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@@ -188,7 +188,7 @@ export class CollabController {
};
try {
const trip = this.requireTrip(tripId, user);
if (!this.collab.canUploadFiles(trip, user)) {
if (!(await this.collab.canUploadFiles(trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
} catch (err) {
@@ -345,12 +345,12 @@ export class CollabController {
let trip;
try {
trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
} catch (err) {
cleanupSpool();
throw err;
}
if (uploaded.length && !this.collab.canUploadFiles(trip, user)) {
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
+10 -10
View File
@@ -96,7 +96,7 @@ export class CollabMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
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 });
this.guards.safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
@@ -127,7 +127,7 @@ export class CollabMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
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 });
if (!note) return errorResult('Note not found.');
this.guards.safeBroadcast(tripId, 'collab:note:updated', { note });
@@ -148,7 +148,7 @@ export class CollabMcp {
async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.collab.deleteNote(tripId, noteId);
if (!deleted) return errorResult('Note not found.');
this.guards.safeBroadcast(tripId, 'collab:note:deleted', { noteId });
@@ -195,7 +195,7 @@ export class CollabMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline });
this.guards.safeBroadcast(tripId, 'collab:poll:created', { poll });
return ok({ poll });
@@ -216,7 +216,7 @@ export class CollabMcp {
async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex);
if (result.error) return errorResult(result.error);
this.guards.safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
@@ -237,7 +237,7 @@ export class CollabMcp {
async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = this.collab.closePoll(tripId, pollId);
if (!poll) return errorResult('Poll not found.');
this.guards.safeBroadcast(tripId, 'collab:poll:closed', { poll });
@@ -258,7 +258,7 @@ export class CollabMcp {
async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.collab.deletePoll(tripId, pollId);
if (!deleted) return errorResult('Poll not found.');
this.guards.safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
@@ -297,7 +297,7 @@ export class CollabMcp {
async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null);
if (result.error) return errorResult(result.error);
this.guards.safeBroadcast(tripId, 'collab:message:created', { message: result.message });
@@ -318,7 +318,7 @@ export class CollabMcp {
async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.deleteMessage(tripId, messageId, ctx.userId);
if (result.error) return errorResult(result.error);
this.guards.safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
@@ -340,7 +340,7 @@ export class CollabMcp {
async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji);
if (!result.found) return errorResult('Message not found.');
this.guards.safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
+20 -20
View File
@@ -31,62 +31,62 @@ export class CollabRpc {
@PluginMethod('collab.listNotes', { permission: 'db:read:collab' })
listNotes(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () => {
this.requireCollabAddon();
return this.guards.tripRead(params, ctx, async () => {
await this.requireCollabAddon();
return this.collab.listNotes(num(params.tripId, 'tripId')) as unknown[];
});
}
@PluginMethod('collab.listPolls', { permission: 'db:read:collab' })
listPolls(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () => {
this.requireCollabAddon();
return this.guards.tripRead(params, ctx, async () => {
await this.requireCollabAddon();
return this.collab.listPolls(num(params.tripId, 'tripId')) as unknown[];
});
}
@PluginMethod('collab.listMessages', { permission: 'db:read:collab' })
listMessages(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () => {
this.requireCollabAddon();
return this.guards.tripRead(params, ctx, async () => {
await this.requireCollabAddon();
const before = params.before != null ? num(params.before, 'before') : undefined;
return this.collab.listMessages(num(params.tripId, 'tripId'), before) as unknown[];
});
}
@PluginMethod('collab.createNote', { permission: 'db:write:collab' })
createNote(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createNote(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'collab note');
const input = asPayload(params.input);
if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required');
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
this.requireCollabAddon();
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
await this.requireCollabAddon();
const note = this.collab.createNote(String(tripId), actor, input as never);
this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined);
return note;
}
@PluginMethod('collab.createPoll', { permission: 'db:write:collab' })
createPoll(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createPoll(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'collab poll');
const input = asPayload(params.input);
if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required');
if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options');
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
this.requireCollabAddon();
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
await this.requireCollabAddon();
const poll = this.collab.createPoll(String(tripId), actor, input as never);
this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined);
return poll;
}
@PluginMethod('collab.votePoll', { permission: 'db:write:collab' })
votePoll(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async votePoll(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'collab poll');
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
this.requireCollabAddon();
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
await this.requireCollabAddon();
const result = this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex'));
// The service reports its own validation failures rather than throwing.
if (result.error) throw new BadParams(result.error);
@@ -95,14 +95,14 @@ export class CollabRpc {
}
@PluginMethod('collab.createMessage', { permission: 'db:write:collab' })
createMessage(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createMessage(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'collab message');
if (typeof params.text !== 'string' || params.text.trim() === '' || params.text.length > 4000) {
throw new BadParams('message text is required (max 4000 chars)');
}
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
this.requireCollabAddon();
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
await this.requireCollabAddon();
const replyTo = typeof params.replyTo === 'number' ? params.replyTo : null;
const result = this.collab.createMessage(String(tripId), actor, params.text, replyTo);
if (result.error) throw new BadParams(result.error);
@@ -110,7 +110,7 @@ export class CollabRpc {
return result.message;
}
private requireCollabAddon(): void {
this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab');
private async requireCollabAddon(): Promise<void> {
await this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab');
}
}
+2 -2
View File
@@ -147,11 +147,11 @@ export class CollabService {
return this.db.canAccessTrip(tripId, userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('collab_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
canUploadFiles(trip: Trip, user: User): boolean {
async canUploadFiles(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('file_upload', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -304,7 +304,7 @@ export class CollectionsMcp {
})
async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) {
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
}
// ── Labels ────────────────────────────────────────────────────────────
+15 -15
View File
@@ -29,56 +29,56 @@ export class CollectionsRpc {
) {}
@PluginMethod('collections.listMine', { permission: 'db:read:collections' })
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireCollectionsUser(ctx, 'reads');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.collections.listCollections(userId);
}
@PluginMethod('collections.get', { permission: 'db:read:collections' })
get(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async get(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// getCollection is user-scoped by the service, so a plugin only ever fetches one
// the acting user may see.
const userId = this.requireCollectionsUser(ctx, 'reads');
const id = num(params.id, 'id');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.collections.getCollection(userId, id);
}
@PluginMethod('collections.create', { permission: 'db:write:collections' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const parsed = collectionCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.createCollection(userId, parsed.data as never));
}
@PluginMethod('collections.update', { permission: 'db:write:collections' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const parsed = collectionUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
const id = num(params.id, 'id');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.updateCollection(userId, id, parsed.data as never, undefined));
}
@PluginMethod('collections.savePlace', { permission: 'db:write:collections' })
savePlace(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async savePlace(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const parsed = collectionSavePlaceRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.savePlace(userId, parsed.data as never, undefined));
}
@PluginMethod('collections.copyToTrip', { permission: 'db:write:collections' })
copyToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async copyToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const parsed = collectionCopyToTripRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid copy request: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never));
}
@@ -86,7 +86,7 @@ export class CollectionsRpc {
async deletePlace(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireCollectionsUser(ctx, 'writes');
const placeId = num(params.placeId, 'placeId');
this.requireCollectionsAddon();
await this.requireCollectionsAddon();
// deletePlace is async (it deletes the underlying storage object): await it so a
// refusal actually reaches the plugin as RESOURCE_FORBIDDEN/BAD_PARAMS instead of
// being dropped as an unhandled rejection while this returns {deleted: true} anyway.
@@ -101,8 +101,8 @@ export class CollectionsRpc {
return ctx.actingUserId;
}
private requireCollectionsAddon(): void {
this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
private async requireCollectionsAddon(): Promise<void> {
await this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
}
/**
@@ -1232,11 +1232,11 @@ export class CollectionsService {
// Copy to trip
// -------------------------------------------------------------------------
copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } {
async copyToTrip(userId: number, body: CollectionCopyToTripRequest): Promise<{ copied: number; skipped: { id: number; name: string }[] }> {
const trip = this.db.canAccessTrip(body.trip_id, userId);
if (!trip) httpError(404, 'Trip not found');
const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user';
if (!this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId)) {
if (!(await this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId))) {
httpError(403, 'Not allowed to edit this trip');
}
+3 -3
View File
@@ -69,7 +69,7 @@ export class DayNotesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.notes.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = this.notes.create(dayId, tripId, text, time, icon, sort_order, color);
this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note });
@@ -100,7 +100,7 @@ export class DayNotesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = this.notes.getNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon, color, sort_order });
@@ -122,7 +122,7 @@ export class DayNotesMcp {
async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const note = this.notes.getNote(noteId, dayId, tripId);
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
this.notes.remove(noteId);
+6 -6
View File
@@ -35,13 +35,13 @@ export class DayNotesRpc {
}
@PluginMethod('daynotes.create', { permission: 'db:write:daynotes' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day note');
const input = asPayload(params.input);
if (typeof input.text !== 'string' || input.text.trim() === '') throw new BadParams('note text is required');
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
if (!this.dayNotes.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
const i = input as DayNoteInput;
const note = this.dayNotes.create(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order);
@@ -50,12 +50,12 @@ export class DayNotesRpc {
}
@PluginMethod('daynotes.update', { permission: 'db:write:daynotes' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const noteId = num(params.noteId, 'noteId');
const actor = this.guards.requireActor(ctx, 'day note');
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
const current = this.dayNotes.getNote(noteId, dayId, tripId);
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
const note = this.dayNotes.update(noteId, current as never, asPayload(params.input) as DayNoteInput);
@@ -64,12 +64,12 @@ export class DayNotesRpc {
}
@PluginMethod('daynotes.delete', { permission: 'db:write:daynotes' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const noteId = num(params.noteId, 'noteId');
const actor = this.guards.requireActor(ctx, 'day note');
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
const current = this.dayNotes.getNote(noteId, dayId, tripId);
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
this.dayNotes.remove(noteId);
@@ -37,7 +37,7 @@ export class DayNotesService {
return this.dbs.canAccessTrip(tripId, userId);
}
canEdit(trip: TripAccess, user: User): boolean {
async canEdit(trip: TripAccess, user: User): Promise<boolean> {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
+6 -6
View File
@@ -61,7 +61,7 @@ export class DaysController {
@RequirePermission('day_edit')
@Put('reorder')
reorder(
async reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: DayReorderDto,
@@ -71,7 +71,7 @@ export class DaysController {
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
}
try {
this.days.reorder(tripId, body.orderedIds);
await this.days.reorder(tripId, body.orderedIds);
} catch (err) {
if (err instanceof DayReorderError) {
throw new HttpException({ error: err.message }, 400);
@@ -84,7 +84,7 @@ export class DaysController {
@RequirePermission('day_edit')
@Put(':id')
update(
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -98,14 +98,14 @@ export class DaysController {
// The zod-parsed body carries only the keys the client actually sent, so
// the service's presence sentinels preserve the omitted column (the client
// updates notes and title in separate requests).
const day = this.days.update(id, current as never, body);
const day = await this.days.update(id, current as never, body);
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
return { day };
}
@RequirePermission('day_edit')
@Put(':id/transport')
transport(
async transport(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@@ -115,7 +115,7 @@ export class DaysController {
if (!this.days.getDay(id, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
const day = this.days.setDefaultTransportMode(id, body.transport_mode ?? null);
const day = await this.days.setDefaultTransportMode(id, body.transport_mode ?? null);
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
return { day };
}
+9 -9
View File
@@ -64,13 +64,13 @@ export class DaysMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const current = this.days.getDay(dayId, tripId);
if (!current) return errorResult('Day not found.');
// The rest spread carries only the keys the caller actually sent, which is
// what update()'s presence sentinels need: naming the two fields here would
// hand it an undefined notes on a title-only call and wipe the day's notes.
const updated = this.days.update(dayId, current, fields);
const updated = await this.days.update(dayId, current, fields);
this.guards.safeBroadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated });
}
@@ -93,7 +93,7 @@ export class DaysMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (position === undefined) {
const day = this.days.create(tripId, date, notes);
this.guards.safeBroadcast(tripId, 'day:created', { day });
@@ -128,9 +128,9 @@ export class DaysMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
try {
this.days.reorder(tripId, orderedIds);
await this.days.reorder(tripId, orderedIds);
} catch (err) {
// A non-permutation and an inverted stay are both the caller's input, so
// they come back as tool errors rather than a throw the SDK has to dress up.
@@ -155,7 +155,7 @@ export class DaysMcp {
async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
this.days.remove(dayId);
// REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id }
@@ -181,9 +181,9 @@ export class DaysMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
const day = await this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
this.guards.safeBroadcast(tripId, 'day:updated', { day });
return ok({ day });
}
@@ -206,7 +206,7 @@ export class DaysMcp {
}],
};
}
const { days } = this.days.list(id);
const { days } = await this.days.list(id);
return {
contents: [{
uri: uri.href,
+7 -7
View File
@@ -24,12 +24,12 @@ export class DaysRpc {
) {}
@PluginMethod('days.create', { permission: 'db:write:days' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const input = parsed.data as { date?: string; notes?: string };
const day = this.days.create(tripId, input.date, input.notes);
this.realtime.broadcast(tripId, 'day:created', { day });
@@ -37,27 +37,27 @@ export class DaysRpc {
}
@PluginMethod('days.update', { permission: 'db:write:days' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
// getDay scopes the row to the trip before the write touches it.
const current = this.days.getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
const day = this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null });
const day = await this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null });
this.realtime.broadcast(tripId, 'day:updated', { day });
return day;
}
@PluginMethod('days.delete', { permission: 'db:write:days' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day');
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
this.days.remove(dayId);
this.realtime.broadcast(tripId, 'day:deleted', { dayId });
+10 -10
View File
@@ -72,7 +72,7 @@ export class DaysService {
return this.db.canAccessTrip(Number(tripId), userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -84,7 +84,7 @@ export class DaysService {
// Day assignment helpers
// -------------------------------------------------------------------------
getAssignmentsForDay(dayId: number | string) {
async getAssignmentsForDay(dayId: number | string) {
const assignments = this.db.all<AssignmentRow>(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -103,7 +103,7 @@ export class DaysService {
// One batched tag load instead of the legacy per-assignment query; the
// non-compact loader returns the same full tag rows (t.* minus the join
// key), so the output shape is unchanged.
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]);
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]);
return assignments.map(a => {
const tags = tagsByPlaceId[a.place_id] || [];
@@ -160,7 +160,7 @@ export class DaysService {
// Day CRUD
// -------------------------------------------------------------------------
list(tripId: string | number) {
async list(tripId: string | number) {
const days = this.db.all<Day>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
if (days.length === 0) {
@@ -186,10 +186,10 @@ export class DaysService {
`, ...dayIds);
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const allAssignmentIds = allAssignments.map(a => a.id);
const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds);
const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds);
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
@@ -233,7 +233,7 @@ export class DaysService {
return this.db.get<Day>('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId);
}
update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
async update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
// Both columns use the presence sentinel: an absent key preserves the
// current value (the legacy version always wrote notes, so setting a title
// silently wiped the day's notes — the client sends the two fields in
@@ -244,7 +244,7 @@ export class DaysService {
id
);
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
}
/**
@@ -252,10 +252,10 @@ export class DaysService {
* notes/title the way the general day update would, and symmetric with the
* per-leg assignment transport setter. Per-segment leg modes still override it.
*/
setDefaultTransportMode(id: string | number, mode: string | null) {
async setDefaultTransportMode(id: string | number, mode: string | null) {
this.db.run('UPDATE days SET default_transport_mode = ? WHERE id = ?', mode ?? null, id);
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
}
remove(id: string | number): void {
@@ -54,8 +54,8 @@ export class DocSyncWebhookController implements OnModuleDestroy {
* well as on arrival, so a switch thrown during the debounce window still
* takes effect.
*/
private syncIsOn(link: LinkRow): boolean {
if (this.sync.isSwitchedOff(link)) return false;
private async syncIsOn(link: LinkRow): Promise<boolean> {
if ((await this.sync.isSwitchedOff(link))) return false;
const killSwitch = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
)?.value;
@@ -72,12 +72,12 @@ export class DocSyncWebhookController implements OnModuleDestroy {
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
nudge(@Param('token') token: string, @Req() req: Request) {
async nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
if (!this.syncIsOn(link)) return { received: true };
if (!(await this.syncIsOn(link))) return { received: true };
// The secret is only known to a provider TREK subscribed at itself, so it
// is only demanded there. A URL pasted into a store by hand (Papra, or a
@@ -108,11 +108,11 @@ export class DocSyncWebhookController implements OnModuleDestroy {
*/
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>, isRetry = false): void {
if (this.pending.has(linkId)) return;
const timer = setTimeout(() => {
const timer = setTimeout(async () => {
this.pending.delete(linkId);
const fresh = reload();
if (!fresh || fresh.sync_enabled !== 1) return;
if (!this.syncIsOn(fresh)) return;
if (!(await this.syncIsOn(fresh))) return;
void this.sync.syncLink(fresh).then((res) => {
// A run that was already in flight answers `busy`, and the changes this
// nudge was about may have landed after that run read the folder. Ask
@@ -382,7 +382,7 @@ export class DocSyncController {
// Refused before the shelved rows are touched, so a binding an admin
// switched off stays exactly as it was and resumes where it stopped. A code
// rather than a sentence: the client says it in the reader's language.
if (this.sync.isSwitchedOff(link)) {
if ((await this.sync.isSwitchedOff(link))) {
throw new HttpException({ error: PROVIDER_DISABLED }, 409);
}
// A person asking for a run is also asking for the rows that gave up to be
+1 -1
View File
@@ -82,7 +82,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
async tick(): Promise<void> {
try {
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return;
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return;
const killSwitch = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED)?.value;
// Unrecognised values mean ON here because the setting is absent by
// default; only an explicit 'false' stops the sync.
+3 -3
View File
@@ -51,9 +51,9 @@ export class DocSyncMcp {
access: { group: 'files', mode: 'read' },
when: documentsAddonOn,
})
getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
async getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
return ok(this.sync.status(tripId));
return ok(await this.sync.status(tripId));
}
@Tool({
@@ -108,7 +108,7 @@ export class DocSyncMcp {
// Refused as the REST route refuses it, before the shelved rows below are
// touched: a binding an admin switched off stays exactly as it was, so it
// resumes where it stopped once the provider is back on.
if (this.sync.isSwitchedOff(link)) {
if ((await this.sync.isSwitchedOff(link))) {
results.push({ linkId: link.id, provider: link.provider_id, state: 'disabled', errorCode: PROVIDER_DISABLED });
continue;
}
+23 -19
View File
@@ -133,8 +133,8 @@ export class DocSyncService {
* resumes exactly where it stopped once the switch is back on. Public because
* a manual run has to refuse before it un-shelves anything.
*/
isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): boolean {
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return true;
async isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): Promise<boolean> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true;
return !this.config.enabledProviderIds().includes(link.provider_id);
}
@@ -174,7 +174,7 @@ export class DocSyncService {
// First, before anything that records a failure: a binding whose provider
// is switched off must come back as it was left. The webhook and a first
// run after binding reach this without asking beforehand.
if (this.isSwitchedOff(link)) {
if ((await this.isSwitchedOff(link))) {
return { state: 'disabled', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
}
// An orphaned binding runs for nobody. Its credential belongs to somebody
@@ -1100,7 +1100,7 @@ export class DocSyncService {
}
/** Per-trip view for the UI: what is synced, what needs attention. */
status(tripId: number): Record<string, unknown> {
async status(tripId: number): Promise<Record<string, unknown>> {
const links = this.config.listLinks(tripId);
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
@@ -1138,22 +1138,26 @@ export class DocSyncService {
.all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
// `map` cannot await the provider-off read, so the projection runs as an
// explicit loop — same order, same rows.
const linkCards = [];
for (const l of links) {
const row = byLink.get(l.id);
linkCards.push({
...this.config.publicLink(l, null),
// Paused rather than failed, so the card can say why nothing moves
// without the binding's own state being touched.
providerOff: await this.isSwitchedOff(l),
holdings: {
inTrek: totalHere.n,
atProvider: Number(row?.atProvider ?? 0),
paired: Number(row?.paired ?? 0),
missing: Number(row?.missing ?? 0),
},
});
}
return {
links: links.map((l) => {
const row = byLink.get(l.id);
return {
...this.config.publicLink(l, null),
// Paused rather than failed, so the card can say why nothing moves
// without the binding's own state being touched.
providerOff: this.isSwitchedOff(l),
holdings: {
inTrek: totalHere.n,
atProvider: Number(row?.atProvider ?? 0),
paired: Number(row?.paired ?? 0),
missing: Number(row?.missing ?? 0),
},
};
}),
links: linkCards,
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
};
}
+10 -10
View File
@@ -57,9 +57,9 @@ export class FeedsMcp {
}
/** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */
private denyTripFeed(tripId: number, userId: number) {
private async denyTripFeed(tripId: number, userId: number) {
if (!this.db.canAccessTrip(tripId, userId)) return noAccess();
if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied();
return null;
}
@@ -75,8 +75,8 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async getTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
const denied = this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
const denied = await this.denyTripFeed(tripId, ctx.userId);
if ((await denied)) return denied;
return ok(this.feeds.getTripToken(String(tripId), ctx.userId, this.base()));
}
@@ -91,8 +91,8 @@ export class FeedsMcp {
})
async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
const denied = this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
const denied = await this.denyTripFeed(tripId, ctx.userId);
if ((await denied)) return denied;
return ok(this.feeds.generateTripToken(String(tripId), ctx.userId, this.base()));
}
@@ -107,8 +107,8 @@ export class FeedsMcp {
})
async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
const denied = this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
const denied = await this.denyTripFeed(tripId, ctx.userId);
if ((await denied)) return denied;
return ok(this.feeds.rotateTripToken(String(tripId), ctx.userId, this.base()));
}
@@ -123,8 +123,8 @@ export class FeedsMcp {
})
async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
const denied = this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
const denied = await this.denyTripFeed(tripId, ctx.userId);
if ((await denied)) return denied;
this.feeds.disableTripToken(String(tripId), ctx.userId);
// Matches the route, which answers the cleared token as a null URL.
return ok({ feed_url: null });
+15 -15
View File
@@ -129,7 +129,7 @@ export class FilesController {
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (!this.files.can('file_upload', trip, user)) {
if (!(await this.files.can('file_upload', trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
} catch (err) {
@@ -173,8 +173,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Put(':id')
update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
if (!this.files.can('file_edit', trip, user)) {
async update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
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);
@@ -194,8 +194,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Patch(':id/star')
star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.files.can('file_edit', trip, user)) {
async star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getFileById(id, tripId);
@@ -210,7 +210,7 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Delete('trash/empty')
async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) {
if (!this.files.can('file_delete', trip, user)) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const deleted = await this.files.emptyTrash(tripId);
@@ -220,7 +220,7 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Delete(':id/permanent')
async permanent(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.files.can('file_delete', trip, user)) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getDeletedFile(id, tripId);
@@ -234,8 +234,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Delete(':id')
remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.files.can('file_delete', trip, user)) {
async remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
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);
@@ -250,8 +250,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Post(':id/restore')
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.files.can('file_delete', trip, user)) {
async restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.files.can('file_delete', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getDeletedFile(id, tripId);
@@ -266,8 +266,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Post(':id/link')
@HttpCode(200) // Express answers link with res.json (200).
link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
if (!this.files.can('file_edit', trip, user)) {
async link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
const file = this.files.getFileById(id, tripId);
@@ -281,8 +281,8 @@ export class FilesController {
@UseGuards(TripAccessGuard)
@Delete(':id/link/:linkId')
unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
if (!this.files.can('file_edit', trip, user)) {
async unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
// deleteFileLink scopes by (linkId, fileId) only, so the file itself has to
+3 -3
View File
@@ -123,7 +123,7 @@ export class FilesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
const current = this.files.getFileById(fileId, tripId);
if (!current) return errorResult('File not found.');
// Same enforcement as the REST route's assertLinkTargets, through the same
@@ -163,7 +163,7 @@ export class FilesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
if (!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
@@ -194,7 +194,7 @@ export class FilesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
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.
+7 -7
View File
@@ -101,7 +101,7 @@ export class FilesRpc {
if (input.content_base64.length > 14 * 1024 * 1024) {
throw new BadParams('file exceeds the 10MB plugin upload cap');
}
this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION);
await this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION);
return this.writeFile(tripId, input as unknown as CreateInput, actor);
}
@@ -148,11 +148,11 @@ export class FilesRpc {
}
@PluginMethod('files.createLink', { permission: 'db:write:files' })
createLink(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createLink(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file link');
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
if (!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.
@@ -166,11 +166,11 @@ export class FilesRpc {
}
@PluginMethod('files.update', { permission: 'db:write:files' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
const current = 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 };
@@ -190,11 +190,11 @@ export class FilesRpc {
}
@PluginMethod('files.softDelete', { permission: 'db:write:files' })
softDelete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async softDelete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
this.guards.requireTripEdit(tripId, actor, DELETE_ACTION);
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);
this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined);
+1 -1
View File
@@ -85,7 +85,7 @@ export class FilesService {
return this.db.canAccessTrip(tripId, userId);
}
can(action: FilePermission, trip: Trip, user: User): boolean {
async can(action: FilePermission, trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission(action, user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -25,12 +25,12 @@ export class FeaturesController {
}
@Get('features')
features() {
async features() {
return {
bookingImport: this.extractor.isAvailable(),
// Addon-level flag (per-user config availability is reported per-file in
// the preview response). Drives whether the client shows AI affordances.
aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING),
aiParsing: await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING),
};
}
}
@@ -234,7 +234,7 @@ export class AirtrailImportService {
const ids = chain.map(f => String(f.id));
try {
const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId);
const { reservation } = this.reservations.create(tripId, mapped as any);
const { reservation } = await this.reservations.create(tripId, mapped as any);
const now = new Date().toISOString();
this.db.prepare(
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
@@ -278,7 +278,7 @@ export class AirtrailImportService {
}
try {
const { reservation } = this.reservations.create(tripId, mapped as any);
const { reservation } = await this.reservations.create(tripId, mapped as any);
const now = new Date().toISOString();
this.db.prepare(
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
@@ -34,8 +34,8 @@ export class AirtrailLinkService {
) {}
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
syncGloballyEnabled(): boolean {
if (!this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
async syncGloballyEnabled(): Promise<boolean> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL))) return false;
const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'");
return row?.value !== 'false';
}
@@ -82,7 +82,7 @@ export class AirtrailLinkService {
* next pull's AirTrail-wins policy can't silently revert the local edit.
*/
async pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
if (!this.syncGloballyEnabled()) return;
if (!(await this.syncGloballyEnabled())) return;
const row = this.db.get<{
id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number;
@@ -38,7 +38,7 @@ export class AirtrailSyncService {
* than a module-level flag, because the service is a container singleton. */
private running = false;
syncGloballyEnabled(): boolean {
async syncGloballyEnabled(): Promise<boolean> {
return this.link.syncGloballyEnabled();
}
@@ -91,7 +91,7 @@ export class AirtrailSyncService {
continue;
}
try {
this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
await this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
this.db.run(
'UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?',
hash,
@@ -110,7 +110,7 @@ export class AirtrailSyncService {
/** Background poll across every connected owner (scheduler). */
async runAirtrailSync(): Promise<void> {
if (this.running) return;
if (!this.link.syncGloballyEnabled()) return;
if (!(await this.link.syncGloballyEnabled())) return;
this.running = true;
let changed = 0;
try {
@@ -132,7 +132,7 @@ export class AirtrailSyncService {
* background poll.
*/
async runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
if (!this.link.syncGloballyEnabled()) return { changed: 0 };
if (!(await this.link.syncGloballyEnabled())) return { changed: 0 };
try {
return { changed: await this.syncOwner(userId) };
} catch (err) {
@@ -100,7 +100,7 @@ export class AirtrailService {
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
if (ssrf.isPrivate) {
this.audit.writeAudit({
await this.audit.writeAudit({
userId,
action: 'airtrail.private_ip_configured',
ip: clientIp,
@@ -13,6 +13,7 @@ import {
type DawarichSuggestionList,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AtlasService } from '../atlas/atlas.service';
import { PlacesService } from '../places/places.service';
import { AssignmentsService } from '../assignments/assignments.service';
@@ -53,6 +54,7 @@ export class DawarichSuggestionsService {
private readonly assignments: AssignmentsService,
private readonly permissions: PermissionsService,
private readonly journey: JourneyDomainService,
private readonly uow: UnitOfWork,
) {}
/**
@@ -175,7 +177,7 @@ export class DawarichSuggestionsService {
* MCP tool and any future caller each shape the failure their own way — the
* atlas domain's `BucketItemExistsError` is the precedent.
*/
accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult {
async accept(userId: number, id: number, body: DawarichAccept, sid?: string): Promise<DawarichAcceptResult> {
const row = this.db.get<SuggestionRow>(
'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
id,
@@ -201,12 +203,12 @@ export class DawarichSuggestionsService {
* written: permission to touch the trip is not permission to attach a place
* to somebody else's day.
*/
private acceptAsPlace(
private async acceptAsPlace(
userId: number,
row: SuggestionRow,
body: DawarichAccept,
sid?: string,
): DawarichAcceptResult {
): Promise<DawarichAcceptResult> {
const tripId = body.tripId ?? row.trip_id;
if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400);
@@ -219,10 +221,10 @@ export class DawarichSuggestionsService {
// owner, a member accepting a stay must be refused exactly as they would be
// in the planner. Asked here rather than in the controller so the MCP tool
// cannot take a different route to the same write.
this.requirePermission('place_edit', access.user_id, userId);
await this.requirePermission('place_edit', access.user_id, userId);
if (body.dayId !== undefined) {
this.requirePermission('day_edit', access.user_id, userId);
await this.requirePermission('day_edit', access.user_id, userId);
if (!this.assignments.dayExists(body.dayId, tripId)) {
throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400);
}
@@ -235,7 +237,7 @@ export class DawarichSuggestionsService {
// a place with no assignment, or a place nobody recorded as accepted — is
// worse than none: the stay would come back as unhandled while the place it
// already produced sat on the trip.
const { created, placeId, assignment } = this.db.transaction(() => {
const { created, placeId, assignment } = await this.uow.transactional(async () => {
const place = this.places.create(String(tripId), {
name: body.name?.trim() || row.name,
lat,
@@ -253,7 +255,7 @@ export class DawarichSuggestionsService {
// because this path made it.
this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id);
const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined;
const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined;
this.markAccepted(row.id, 'place', { placeId: id });
// Re-read, so what goes out on the wire carries the source: everyone else
// on the trip should see the Dawarich mark on it too, not only the person
@@ -379,16 +381,16 @@ export class DawarichSuggestionsService {
* tool holds `ctx.userId`, and a permission that depended on which door
* somebody came through would not be a permission.
*/
private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void {
private async requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): Promise<void> {
const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
const allowed = this.permissions.checkPermission(
const allowed = await this.permissions.checkPermission(
action,
actor?.role ?? 'user',
tripOwnerId,
userId,
tripOwnerId !== userId,
);
if (!allowed) throw new AcceptError('forbidden', 'No permission', 403);
if (!(await allowed)) throw new AcceptError('forbidden', 'No permission', 403);
}
/**
@@ -73,13 +73,13 @@ export class DawarichSyncService {
) {}
/** The addon gate, evaluated per tick so an admin toggle lands without a restart. */
syncGloballyEnabled(): boolean {
async syncGloballyEnabled(): Promise<boolean> {
return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH);
}
/** Every connected user, one after another. The cron's entry point. */
async runSync(): Promise<void> {
if (!this.syncGloballyEnabled()) return;
if (!(await this.syncGloballyEnabled())) return;
if (this.running) return;
this.running = true;
try {
@@ -132,7 +132,7 @@ export class DawarichSyncService {
/** The body of a sync, with the guard above already held. */
private async syncUserOnce(userId: number): Promise<DawarichSyncOutcome> {
if (!this.syncGloballyEnabled()) {
if (!(await this.syncGloballyEnabled())) {
this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
return { state: 'failed', created: 0, updated: 0, missing: 0 };
}
@@ -97,8 +97,8 @@ export class DawarichController {
@Delete('settings')
@HttpCode(200)
disconnect(@CurrentUser() user: User, @Req() req: Request) {
this.dawarich.disconnect(user.id, getClientIp(req));
async disconnect(@CurrentUser() user: User, @Req() req: Request) {
await this.dawarich.disconnect(user.id, getClientIp(req));
this.tracks.forget(user.id);
return { success: true };
}
@@ -138,7 +138,10 @@ export class DawarichController {
@Body() body: DawarichAcceptDto,
@Req() req: Request,
) {
return this.guard(() =>
// guardAsync, not guard: accept() is async now, so its AcceptError arrives as
// a rejection a synchronous try/catch cannot see — and the domain's own code
// would be dropped from the body.
return this.guardAsync(() =>
this.suggestions.accept(user.id, parseId(id), body, socketId(req)),
);
}
@@ -122,7 +122,7 @@ export class DawarichService {
return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' };
}
if (ssrf.isPrivate) {
this.audit.writeAudit({
await this.audit.writeAudit({
userId,
action: 'dawarich.private_ip_configured',
ip: clientIp,
@@ -212,9 +212,9 @@ export class DawarichService {
* their journal because they revoked an API key would be the integration
* destroying user content on its way out. What goes is the credential.
*/
disconnect(userId: number, clientIp: string | null): void {
async disconnect(userId: number, clientIp: string | null): Promise<void> {
this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId);
this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
await this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
}
/**
+17 -17
View File
@@ -50,17 +50,17 @@ export class JournalRpc {
) {}
@PluginMethod('journal.listMine', { permission: 'db:read:journal' })
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'reads');
this.requireJourneyAddon();
await this.requireJourneyAddon();
return this.journey.listJourneys(userId);
}
@PluginMethod('journal.getEntries', { permission: 'db:read:journal' })
getEntries(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async getEntries(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'reads');
const journeyId = num(params.journeyId, 'journeyId');
this.requireJourneyAddon();
await this.requireJourneyAddon();
// listEntries self-gates via canAccessJourney and returns null when the user
// cannot see it.
const entries = this.journey.listEntries(journeyId, userId);
@@ -69,32 +69,32 @@ export class JournalRpc {
}
@PluginMethod('journal.createEntry', { permission: 'db:write:journal' })
createEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'writes');
const input = asPayload(params.input);
if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required');
const journeyId = num(params.journeyId, 'journeyId');
this.requireJourneyAddon();
await this.requireJourneyAddon();
const entry = this.journey.createEntry(journeyId, userId, input as never);
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
return entry;
}
@PluginMethod('journal.updateEntry', { permission: 'db:write:journal' })
updateEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async updateEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'writes');
const entryId = num(params.entryId, 'entryId');
this.requireJourneyAddon();
await this.requireJourneyAddon();
const entry = this.journey.updateEntry(entryId, userId, asPayload(params.input) as never);
if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
return entry;
}
@PluginMethod('journal.deleteEntry', { permission: 'db:write:journal' })
deleteEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async deleteEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'writes');
const entryId = num(params.entryId, 'entryId');
this.requireJourneyAddon();
await this.requireJourneyAddon();
if (!this.journey.deleteEntry(entryId, userId)) {
throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
}
@@ -102,10 +102,10 @@ export class JournalRpc {
}
@PluginMethod('journal.createJourney', { permission: 'db:write:journal' })
createJourney(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createJourney(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'writes');
const input = asPayload(params.input);
this.requireJourneyAddon();
await this.requireJourneyAddon();
const title = typeof input.title === 'string' ? input.title.trim() : '';
if (!title) throw new BadParams('journal title is required');
return this.journey.createJourney(userId, {
@@ -137,7 +137,7 @@ export class JournalRpc {
const entryId = num(params.entryId, 'entryId');
// num() accepts "3" and 1.5; a row id is neither.
if (!Number.isInteger(entryId) || entryId <= 0) throw new BadParams('entryId must be a positive integer');
this.requireJourneyAddon();
await this.requireJourneyAddon();
const parsed = journalPluginPhotoInputSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid photo input: ${parsed.error.issues[0]?.message ?? 'unknown'}`);
@@ -188,10 +188,10 @@ export class JournalRpc {
}
@PluginMethod('journal.deleteJourney', { permission: 'db:write:journal' })
deleteJourney(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async deleteJourney(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const userId = this.requireJournalUser(ctx, 'writes');
const journeyId = num(params.journeyId, 'journeyId');
this.requireJourneyAddon();
await this.requireJourneyAddon();
if (!this.journey.deleteJourney(journeyId, userId)) {
throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
}
@@ -205,7 +205,7 @@ export class JournalRpc {
return ctx.actingUserId;
}
private requireJourneyAddon(): void {
this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey');
private async requireJourneyAddon(): Promise<void> {
await this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey');
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ export class JourneyService {
return this.dbs.connection;
}
journeyAddonEnabled(): boolean {
async journeyAddonEnabled(): Promise<boolean> {
return this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
}
@@ -28,8 +28,8 @@ export class LlmConfigResolver {
* else null. This is the single place the API key is decrypted, and the single
* place that decides which endpoint the server is allowed to call (#1772).
*/
resolve(userId: number): ResolvedLlmConfig | null {
if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
return this.readInstanceConfig() ?? this.readUserConfig(userId);
}
@@ -33,12 +33,12 @@ export class LlmParseService {
) {}
/** True when the addon is enabled AND a usable config resolves for this user. */
isAvailable(userId: number): boolean {
return this.llmConfig.resolve(userId) !== null;
async isAvailable(userId: number): Promise<boolean> {
return (await this.llmConfig.resolve(userId)) !== null;
}
async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise<LlmParseResult> {
const config = this.llmConfig.resolve(userId);
const config = await this.llmConfig.resolve(userId);
if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] };
const warnings: string[] = [];
@@ -65,7 +65,7 @@ export class McpToolGuardsService {
* matching REST route uses. Returns true when the user may perform `action`
* on `tripId`.
*/
hasTripPermission(action: string, tripId: number | string, userId: number): boolean {
async hasTripPermission(action: string, tripId: number | string, userId: number): Promise<boolean> {
const trip = this.db.get<{ user_id?: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) return false;
const userRow = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', userId);
@@ -159,7 +159,7 @@ export class McpTransportService {
}
async handle(req: Request, res: Response): Promise<void> {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
@@ -281,22 +281,25 @@ export class McpTransportService {
const transportHolder: { current: StreamableHTTPServerTransport | null } = { current: null };
const onInvoke = (info: { kind: string; name: string }): void => {
if (info.kind !== 'tool') return;
try {
// The registry fires this from the SDK's tool callback and cannot await it,
// so the now-async write runs on its own and reports its own failure —
// which is what the try/catch here always did (recipe R1.5).
void (async () => {
const sid = transportHolder.current?.sessionId;
const ip = (sid ? sessions.get(sid)?.lastClientIp : null) ?? createIp;
this.audit.writeAudit({
await this.audit.writeAudit({
userId: user.id,
action: 'mcp.tool_call',
resource: info.name,
details: { clientId: clientId ?? 'native' },
ip,
});
} catch (err) {
})().catch((err: unknown) => {
console.error('[MCP] tool-call audit failed:', (err as Error | undefined)?.message ?? err);
}
});
};
registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke);
await registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
+1 -1
View File
@@ -105,7 +105,7 @@ export class ImmichService {
userId
);
if (ssrf.isPrivate) {
this.audit.writeAudit({
await this.audit.writeAudit({
userId,
action: 'immich.private_ip_configured',
ip: clientIp,
+12 -12
View File
@@ -38,8 +38,8 @@ const SYNOLOGY_DEFAULT_LIMIT = 100;
* provider counts as on, whatever its row says. Which of the two providers is
* on is a per-call question, answered by providerRefusal().
*/
const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean =>
self.enabledProviderIds().length > 0;
const anyPhotoProviderEnabled = async (_ctx: McpContext, self: MemoriesMcp): Promise<boolean> =>
(await self.enabledProviderIds()).length > 0;
/**
* Memories MCP surface: finding photos in a connected Immich or Synology Photos
@@ -73,8 +73,8 @@ export class MemoriesMcp {
* Public because the `when:` gate above is a module-level function rather than
* a method, the same reason the addon gates need a public `addons`.
*/
enabledProviderIds(): string[] {
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return [];
async enabledProviderIds(): Promise<string[]> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id);
}
@@ -84,10 +84,10 @@ export class MemoriesMcp {
* disabled provider refuses the same way on both surfaces. The browse routes
* themselves never checked it, so this only ever narrows what REST allows.
*/
private providerRefusal(provider: ProviderId) {
private async providerRefusal(provider: ProviderId) {
const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider);
if (!row) return errorResult(`Provider: "${provider}" is not supported`);
if (row.enabled !== 1 || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))
if (row.enabled !== 1 || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)))
return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
return null;
}
@@ -111,8 +111,8 @@ export class MemoriesMcp {
{ provider, from, to, page, size, utc_offset_minutes }: { provider: ProviderId; from?: string; to?: string; page?: number; size?: number; utc_offset_minutes?: number },
ctx: McpContext,
) {
const refused = this.providerRefusal(provider);
if (refused) return refused;
const refused = await this.providerRefusal(provider);
if ((await refused)) return refused;
if (provider === 'immich') {
// Same coercion the REST route performs on the body before calling.
@@ -144,8 +144,8 @@ export class MemoriesMcp {
access: { group: 'journey', mode: 'read' },
})
async listProviderAlbums({ provider }: { provider: ProviderId }, ctx: McpContext) {
const refused = this.providerRefusal(provider);
if (refused) return refused;
const refused = await this.providerRefusal(provider);
if ((await refused)) return refused;
if (provider === 'immich') {
const result = await this.immich.listAlbums(ctx.userId);
@@ -177,8 +177,8 @@ export class MemoriesMcp {
{ provider, album_id, passphrase }: { provider: ProviderId; album_id: string; passphrase?: string },
ctx: McpContext,
) {
const refused = this.providerRefusal(provider);
if (refused) return refused;
const refused = await this.providerRefusal(provider);
if ((await refused)) return refused;
if (provider === 'immich') {
const result = await this.immich.getAlbumPhotos(ctx.userId, album_id);
@@ -37,7 +37,7 @@ export class ThumbnailService {
async ensureLocalThumbnail(
originalRelPath: string,
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return null
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return null
// The DB stores uploads-relative paths ('journey/<file>'); anything else
// is not a local journey photo and has no thumbnail to derive.
@@ -88,7 +88,7 @@ export class ThumbnailService {
* (airtrail-sync.job.ts precedent).
*/
async sweepOrphanThumbs(): Promise<number> {
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return 0
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0
const live = new Set<string>()
for (const row of this.db.all<{ file_path: string }>(
@@ -36,17 +36,17 @@ export class UnifiedMemoriesService {
private readonly addons: AddonsService,
) {}
private _providers(): Array<{id: string; enabled: boolean}> {
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
// A provider only counts as enabled while the journey addon is — its whole
// surface lives inside journeys. Covers rows left enabled from before
// updateAddon cascaded the journey disable.
const journeyOn = this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
const journeyOn = await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 }));
}
private _validProvider(provider: string): ServiceResult<string> {
const providers = this._providers();
private async _validProvider(provider: string): Promise<ServiceResult<string>> {
const providers = await this._providers();
const found = providers.find(p => p.id === provider);
if (!found) {
return fail(`Provider: "${provider}" is not supported`, 400);
@@ -60,7 +60,7 @@ export class UnifiedMemoriesService {
listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
async listTripPhotos(tripId: string, userId: number): Promise<ServiceResult<any[]>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -68,7 +68,7 @@ export class UnifiedMemoriesService {
try {
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
@@ -92,14 +92,14 @@ export class UnifiedMemoriesService {
}
}
listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
async listTripAlbumLinks(tripId: string, userId: number): Promise<ServiceResult<any[]>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
}
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id);
if (enabledProviders.length === 0) {
return fail('No photo providers enabled', 400);
@@ -133,8 +133,8 @@ export class UnifiedMemoriesService {
//-----------------------------------------------
// managing photos in trip
private _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
const providerResult = this._validProvider(provider);
private async _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): Promise<ServiceResult<boolean>> {
const providerResult = await this._validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<boolean>;
}
@@ -169,14 +169,14 @@ export class UnifiedMemoriesService {
let added = 0;
for (const selection of selections) {
const providerResult = this._validProvider(selection.provider);
const providerResult = await this._validProvider(selection.provider);
if (!providerResult.success) {
return providerResult as ServiceResult<{ added: number; shared: boolean }>;
}
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
const result = this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
const result = await this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
if (!result.success) {
return result as ServiceResult<{ added: number; shared: boolean }>;
}
@@ -252,7 +252,7 @@ export class UnifiedMemoriesService {
// ----------------------------------------------
// managing album links in trip
createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
async createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): Promise<ServiceResult<true>> {
const access = this.db.canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -270,7 +270,7 @@ export class UnifiedMemoriesService {
}
const providerResult = this._validProvider(provider);
const providerResult = await this._validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<true>;
}
@@ -22,8 +22,8 @@ export class UnifiedMemoriesController {
constructor(private readonly memories: MemoriesService) {}
@Get('trips/:tripId/photos')
listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripPhotos(tripId, user.id);
async listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise<void> {
const result = await this.memories.listTripPhotos(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -81,8 +81,8 @@ export class UnifiedMemoriesController {
}
@Get('trips/:tripId/album-links')
listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
const result = this.memories.listTripAlbumLinks(tripId, user.id);
async listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise<void> {
const result = await this.memories.listTripAlbumLinks(tripId, user.id);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
@@ -92,14 +92,14 @@ export class UnifiedMemoriesController {
@Post('trips/:tripId/album-links')
@HttpCode(200)
createAlbumLink(
async createAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: CreateAlbumLinkDto,
@Res() res: Response,
): void {
): Promise<void> {
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
const result = await this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
if ('error' in result) {
res.status(result.error.status).json({ error: result.error.message });
return;
+25 -25
View File
@@ -25,25 +25,25 @@ const MIN = 60_000;
export class OauthApiController {
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
private requireMcp403(): void {
if (!this.oauth.mcpEnabled()) {
private async requireMcp403(): Promise<void> {
if (!(await this.oauth.mcpEnabled())) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
}
@Get('authorize/validate')
@UseGuards(OptionalJwtGuard)
validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
async validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) {
throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429);
}
if (!this.oauth.mcpEnabled()) {
if (!(await this.oauth.mcpEnabled())) {
// 404 (not 403) with an empty body so anonymous callers can't fingerprint the feature.
res.status(404).end();
return undefined;
}
const userId = (req.user as User | undefined)?.id ?? null;
const result = this.oauth.validateAuthorizeRequest(
const result = await this.oauth.validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
@@ -68,9 +68,9 @@ export class OauthApiController {
@Post('authorize')
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
@UseGuards(CookieAuthGuard)
authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
async authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
const ip = getClientIp(req);
if (!this.oauth.mcpEnabled()) {
if (!(await this.oauth.mcpEnabled())) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
}
const params: AuthorizeParams = {
@@ -87,7 +87,7 @@ export class OauthApiController {
// of body.redirect_uri too, and only validateAuthorizeRequest checks it
// against the client's registered URIs. A string that is not a URL at all
// would additionally throw out of `new URL()` as a 500.
const validation = this.oauth.validateAuthorizeRequest(params, user.id);
const validation = await this.oauth.validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400);
}
@@ -99,7 +99,7 @@ export class OauthApiController {
return { redirect: url.toString() };
}
const scopes = validation.scopes!;
this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
const code = this.oauth.createAuthCode({
clientId: body.client_id,
userId: user.id,
@@ -120,17 +120,17 @@ export class OauthApiController {
@Get('clients')
@UseGuards(JwtAuthGuard)
listClients(@CurrentUser() user: User) {
this.requireMcp403();
async listClients(@CurrentUser() user: User) {
await this.requireMcp403();
return { clients: this.oauth.listOAuthClients(user.id) };
}
@Post('clients')
@HttpCode(201)
@UseGuards(CookieAuthGuard)
createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
await this.requireMcp403();
const result = await this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
@@ -140,9 +140,9 @@ export class OauthApiController {
@Post('clients/:id/rotate')
@HttpCode(200)
@UseGuards(CookieAuthGuard)
rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
@@ -151,9 +151,9 @@ export class OauthApiController {
@Delete('clients/:id')
@UseGuards(CookieAuthGuard)
deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
@@ -162,16 +162,16 @@ export class OauthApiController {
@Get('sessions')
@UseGuards(JwtAuthGuard)
listSessions(@CurrentUser() user: User) {
this.requireMcp403();
async listSessions(@CurrentUser() user: User) {
await this.requireMcp403();
return { sessions: this.oauth.listOAuthSessions(user.id) };
}
@Delete('sessions/:id')
@UseGuards(CookieAuthGuard)
revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
@@ -33,8 +33,8 @@ export class OauthPublicController {
@Post('token')
@HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201).
token(@Req() req: Request, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
async token(@Req() req: Request, @Res() res: Response): Promise<void> {
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) {
@@ -59,8 +59,8 @@ export class OauthPublicController {
return;
}
const pending = this.oauth.consumeAuthCode(code);
const invalidGrant = (reason: string, userId: number | null) => {
this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
const invalidGrant = async (reason: string, userId: number | null) => {
await this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
};
if (!pending) return invalidGrant('code_invalid_or_expired', null);
@@ -69,13 +69,13 @@ export class OauthPublicController {
if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId);
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
res.json(tokens);
return;
}
@@ -85,7 +85,7 @@ export class OauthPublicController {
res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
return;
}
const result = this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
const result = await this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
res.status(result.status || 400).json({ error: result.error, error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired' });
@@ -103,12 +103,12 @@ export class OauthPublicController {
const client = this.oauth.authenticateClient(client_id, client_secret);
if (!client) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
await this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
return;
}
@@ -127,7 +127,7 @@ export class OauthPublicController {
}
const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`;
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
await this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
res.json(tokens);
return;
}
@@ -136,8 +136,8 @@ export class OauthPublicController {
}
@Get('userinfo')
userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
async userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): Promise<void> {
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
res.status(401).json({ error: 'invalid_token' });
@@ -153,8 +153,8 @@ export class OauthPublicController {
}
@Post('revoke')
revoke(@Req() req: Request, @Res() res: Response): void {
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
async revoke(@Req() req: Request, @Res() res: Response): Promise<void> {
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
@@ -168,11 +168,11 @@ export class OauthPublicController {
}
if (!this.oauth.authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return;
}
this.oauth.revokeToken(token, client_id, undefined, ip);
await this.oauth.revokeToken(token, client_id, undefined, ip);
res.status(200).json({});
}
}
+4 -4
View File
@@ -86,7 +86,7 @@ export class TrekClientsStore implements OAuthRegisteredClientsStore {
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
const result = this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
const result = await this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
if (result.error) throw new InvalidClientMetadataError(result.error);
const c = result.client!;
@@ -179,7 +179,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
throw new Error('Authorization grant is invalid.');
const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
this.audit.writeAudit({
await this.audit.writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
@@ -194,7 +194,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
_scopes?: string[],
_resource?: URL,
): Promise<OAuthTokens> {
const result = this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null);
const result = await this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null);
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
return result.tokens!;
}
@@ -214,6 +214,6 @@ export class TrekOAuthProvider implements OAuthServerProvider {
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
this.oauth.revokeToken(request.token, client.client_id, undefined, null);
await this.oauth.revokeToken(request.token, client.client_id, undefined, null);
}
}
+25 -25
View File
@@ -85,7 +85,7 @@ export class OauthService {
private readonly audit: AuditService,
) {}
mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
async mcpEnabled(): Promise<boolean> { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
mcpSafeUrl(): string { return getMcpSafeUrl(); }
// -------------------------------------------------------------------------
@@ -106,14 +106,14 @@ export class OauthService {
}));
}
createOAuthClient(
async createOAuthClient(
userId: number | null,
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record<string, unknown> } {
): Promise<{ error?: string; status?: number; client?: Record<string, unknown> }> {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
const isMachineClient = Boolean(options?.allowsClientCredentials);
@@ -163,7 +163,7 @@ export class OauthService {
id,
)!;
this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
await this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return {
client: {
@@ -183,11 +183,11 @@ export class OauthService {
};
}
rotateOAuthClientSecret(
async rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
): Promise<{ error?: string; status?: number; client_secret?: string }> {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
@@ -203,20 +203,20 @@ export class OauthService {
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
await this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
deleteOAuthClient(
async deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
): Promise<{ error?: string; status?: number; success?: boolean }> {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
this.db.run('DELETE FROM oauth_clients WHERE id = ?', clientRowId);
this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
await this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
@@ -253,7 +253,7 @@ export class OauthService {
return row ? JSON.parse(row.scopes) : null;
}
saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise<void> {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = this.getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
@@ -261,7 +261,7 @@ export class OauthService {
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
clientId, userId, JSON.stringify(merged),
);
this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
await this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
@@ -446,12 +446,12 @@ export class OauthService {
return !!successor;
}
refreshTokens(
async refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string | undefined,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> } {
): Promise<{ error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> }> {
const client = this.db.get<OAuthClientRow>('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId);
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
@@ -478,7 +478,7 @@ export class OauthService {
// parent so each client walks away with its own token.
if (this.isConcurrentRotation(row)) {
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
this.audit.writeAudit({
await this.audit.writeAudit({
userId: row.user_id,
action: 'oauth.token.refresh',
details: { client_id: clientId, concurrent: true },
@@ -493,7 +493,7 @@ export class OauthService {
revokeUserSessionsForClient(row.user_id, clientId);
this.audit.writeAudit({
await this.audit.writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
@@ -515,7 +515,7 @@ export class OauthService {
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
@@ -524,7 +524,7 @@ export class OauthService {
// Token revocation
// -------------------------------------------------------------------------
revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
async revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): Promise<void> {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
@@ -542,7 +542,7 @@ export class OauthService {
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
await this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
@@ -564,11 +564,11 @@ export class OauthService {
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
revokeSession(
async revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
): Promise<{ error?: string; status?: number; success?: boolean }> {
const row = this.db.get<{ id: number; client_id: string }>('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?', sessionId, userId);
if (!row) return { error: 'Session not found', status: 404 };
@@ -576,7 +576,7 @@ export class OauthService {
revokeUserSessionsForClient(userId, row.client_id);
this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
await this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
@@ -585,11 +585,11 @@ export class OauthService {
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// -------------------------------------------------------------------------
validateAuthorizeRequest(
async validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
): Promise<ValidateAuthorizeResult> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
+4 -4
View File
@@ -165,14 +165,14 @@ export class OidcController {
userInfo.picture = idVerify.claims.picture;
}
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
const result = await this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
if ('error' in result) return f('/login?oidc_error=' + result.error);
if (result.roleChange) {
// The claim mapping changing someone's privileges is a security event, and
// the row is written here because this is where the client IP is. The claim
// NAME goes in the details, never its value: that column is readable by
// every admin and a claim can carry group memberships and worse.
this.audit.writeAudit({
await this.audit.writeAudit({
userId: result.user.id,
action: 'oidc.role_change',
resource: String(result.user.id),
@@ -251,14 +251,14 @@ export class AdminOidcController {
@ManagedForbidden('an instance-supplied issuer could assert any address as verified')
@Put()
update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
const result = this.oidc.updateOidcSettings(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
// Only whether an issuer was set, never the value: the details column is readable
// by every admin and the issuer identifies the customer's IdP tenant.
this.audit.writeAudit({
await this.audit.writeAudit({
userId: user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
+7 -5
View File
@@ -13,6 +13,7 @@ import { TripMembershipService } from '../trip-membership/trip-membership.servic
import { setAuthCookie, RememberOption } from '../common/cookie';
import { AuthService } from '../auth/auth.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
// ---------------------------------------------------------------------------
@@ -228,6 +229,7 @@ export class OidcService implements OnModuleDestroy {
private readonly db: DatabaseService,
private readonly auth: AuthService,
private readonly membership: TripMembershipService,
private readonly uow: UnitOfWork,
) {
this.stateSweeper = setInterval(() => {
const now = Date.now();
@@ -607,11 +609,11 @@ export class OidcService implements OnModuleDestroy {
// Find or create user by OIDC sub / email
// -------------------------------------------------------------------------
findOrCreateUser(
async findOrCreateUser(
userInfo: OidcUserInfo,
config: OidcConfig,
inviteToken?: string,
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
): Promise<{ user: User; roleChange?: OidcRoleChange } | { error: string }> {
// Defense-in-depth for direct callers — the controller redirects on a
// missing email before it ever calls this; the same code flows through its
// `oidc_error=' + result.error` pass-through if reached here.
@@ -731,7 +733,7 @@ export class OidcService implements OnModuleDestroy {
// both pass the earlier SELECT-based check and each create a user.
const inviteRaceError = new Error('invite_exhausted');
try {
const result = this.db.transaction(() => {
const result = (await this.uow.transactional(async () => {
if (validInvite) {
const updated = this.db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
@@ -744,10 +746,10 @@ export class OidcService implements OnModuleDestroy {
// Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the
// same atomic step as the invite consume. Idempotent + owner-safe.
if (validInvite?.trip_id) {
this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null);
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null);
}
return ins;
}) as { lastInsertRowid: number | bigint };
})) as { lastInsertRowid: number | bigint };
// Re-select so the returned User carries the real row (password_version,
// is_guest, created_at, …) instead of a hand-built partial — same shape
// the existing-user branch returns.
@@ -50,9 +50,9 @@ export class AdminPackingTemplatesController {
@Post()
@HttpCode(201)
create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
const result = ok(this.packing.createPackingTemplate(body.name, user.id));
this.audit.writeAudit({
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_create',
resource: String((result.template as { id?: number } | undefined)?.id ?? ''),
@@ -68,9 +68,9 @@ export class AdminPackingTemplatesController {
}
@Delete(':id')
remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.packing.deletePackingTemplate(id));
this.audit.writeAudit({
await this.audit.writeAudit({
userId: user.id,
action: 'admin.packing_template_delete',
resource: String(id),
+14 -14
View File
@@ -81,7 +81,7 @@ export class PackingMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.createItem(tripId, {
name,
category: category || 'General',
@@ -118,7 +118,7 @@ export class PackingMcp {
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
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);
if (!item) return errorResult('Packing item not found.');
// Scoped to the people who may see it, exactly as the REST route does
@@ -141,7 +141,7 @@ export class PackingMcp {
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId);
if (!deleted) return errorResult('Packing item not found.');
// deleteItem hands back the row it removed, so the delete can be scoped to
@@ -176,7 +176,7 @@ export class PackingMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
// The service reads presence from bodyKeys, so a field has to be named there
// for an explicit null to clear it rather than read as "leave it alone".
@@ -232,7 +232,7 @@ export class PackingMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
if (!item) return errorResult('Packing item not found.');
if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.');
@@ -260,7 +260,7 @@ export class PackingMcp {
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.reorderItems(tripId, orderedIds);
this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
@@ -298,7 +298,7 @@ export class PackingMcp {
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 (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
// listBags and the schema always carry, so the client/AI consumer matches.
const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
@@ -327,7 +327,7 @@ export class PackingMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
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 } = {};
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
@@ -358,7 +358,7 @@ export class PackingMcp {
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
this.packing.deleteBag(tripId, bagId);
// { bagId } matches the REST route and the plugin host (the legacy
// registrar's { id } was the odd one out).
@@ -384,7 +384,7 @@ export class PackingMcp {
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const members = this.packing.setBagMembers(tripId, bagId, userIds);
if (!members) return errorResult('Bag not found.');
this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
@@ -422,7 +422,7 @@ export class PackingMcp {
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
return ok({ assignees });
@@ -442,7 +442,7 @@ export class PackingMcp {
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const items = this.packing.applyTemplate(tripId, templateId);
if (items === null) return errorResult('Template not found.');
this.guards.safeBroadcast(tripId, 'packing:template-applied', { items });
@@ -479,7 +479,7 @@ export class PackingMcp {
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
@@ -530,7 +530,7 @@ export class PackingMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const created = this.packing.bulkImport(tripId, items, ctx.userId);
for (const item of created) {
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
+14 -14
View File
@@ -41,12 +41,12 @@ export class PackingRpc {
}
@PluginMethod('packing.create', { permission: 'db:write:packing' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'packing item');
const parsed = packingCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
// A referenced bag must exist on this trip (#2154), as on the REST route.
if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`);
@@ -56,13 +56,13 @@ export class PackingRpc {
}
@PluginMethod('packing.update', { permission: 'db:write:packing' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const itemId = num(params.itemId, 'itemId');
const actor = this.guards.requireActor(ctx, 'packing item');
const parsed = packingUpdateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
// Read the privacy BEFORE the write, so a public/private toggle routes correctly.
const before = this.packing.getItemPrivacy(tripId, itemId);
const input = parsed.data as Record<string, unknown>;
@@ -79,11 +79,11 @@ export class PackingRpc {
}
@PluginMethod('packing.delete', { permission: 'db:write:packing' })
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const itemId = num(params.itemId, 'itemId');
const actor = this.guards.requireActor(ctx, 'packing item');
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined);
@@ -99,12 +99,12 @@ export class PackingRpc {
}
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
createBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async createBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'packing bag');
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required');
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const bag = this.packing.createBag(String(tripId), {
name: input.name,
color: typeof input.color === 'string' ? input.color : undefined,
@@ -115,11 +115,11 @@ export class PackingRpc {
}
@PluginMethod('packing.updateBag', { permission: 'db:write:packing' })
updateBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async updateBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const bagId = num(params.bagId, 'bagId');
const actor = this.guards.requireActor(ctx, 'packing bag');
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const input = asPayload(params.input);
const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
@@ -128,11 +128,11 @@ export class PackingRpc {
}
@PluginMethod('packing.deleteBag', { permission: 'db:write:packing' })
deleteBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async deleteBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const bagId = num(params.bagId, 'bagId');
const actor = this.guards.requireActor(ctx, 'packing bag');
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
if (!this.packing.deleteBag(String(tripId), String(bagId))) {
throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
}
@@ -143,11 +143,11 @@ export class PackingRpc {
}
@PluginMethod('packing.setBagMembers', { permission: 'db:write:packing' })
setBagMembers(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async setBagMembers(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const bagId = num(params.bagId, 'bagId');
const actor = this.guards.requireActor(ctx, 'packing bag');
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
// userIds sits on the params object itself, not under `input`.
const raw = asPayload(params).userIds;
const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : [];
+1 -1
View File
@@ -67,7 +67,7 @@ export class PackingService {
}
/** Mirrors the inline checkPermission('packing_edit', ...) the legacy route runs. */
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { logError } from '../audit/audit-log.logger';
import {
getPermissionsCache,
@@ -73,9 +74,12 @@ const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
@Injectable()
export class PermissionsService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
private readonly dbs: DatabaseService,
private readonly uow: UnitOfWork,
) {}
private loadPermissions(): Map<string, PermissionLevel> {
private async loadPermissions(): Promise<Map<string, PermissionLevel>> {
const cached = getPermissionsCache();
if (cached) return cached;
const cache = new Map<string, PermissionLevel>();
@@ -113,16 +117,16 @@ export class PermissionsService {
invalidateSharedCache();
}
getPermissionLevel(actionKey: string): PermissionLevel {
const perms = this.loadPermissions();
async getPermissionLevel(actionKey: string): Promise<PermissionLevel> {
const perms = await this.loadPermissions();
const stored = perms.get(actionKey);
if (stored) return stored;
const action = ACTIONS_MAP.get(actionKey);
return action?.defaultLevel ?? 'trip_owner';
}
getAllPermissions(): Record<string, PermissionLevel> {
const perms = this.loadPermissions();
async getAllPermissions(): Promise<Record<string, PermissionLevel>> {
const perms = await this.loadPermissions();
const result: Record<string, PermissionLevel> = {};
for (const action of PERMISSION_ACTIONS) {
result[action.key] = perms.get(action.key) ?? action.defaultLevel;
@@ -130,7 +134,7 @@ export class PermissionsService {
return result;
}
savePermissions(settings: Record<string, string>): { skipped: string[] } {
async savePermissions(settings: Record<string, string>): Promise<{ skipped: string[] }> {
const skipped: string[] = [];
const valid: Array<[string, string]> = [];
for (const [actionKey, level] of Object.entries(settings)) {
@@ -144,7 +148,7 @@ export class PermissionsService {
// Nothing valid to write → no prepare, no transaction, no cache flush.
if (valid.length === 0) return { skipped };
const upsert = this.dbs.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const [actionKey, level] of valid) {
upsert.run(`perm_${actionKey}`, level);
}
@@ -162,17 +166,17 @@ export class PermissionsService {
* @param userId - The requesting user's ID
* @param isMember - Whether the user is a trip member (not owner)
*/
checkPermission(
async checkPermission(
actionKey: string,
userRole: string,
tripUserId: number | null,
userId: number,
isMember: boolean
): boolean {
): Promise<boolean> {
// Admins always pass
if (userRole === 'admin') return true;
const required = this.getPermissionLevel(actionKey);
const required = await this.getPermissionLevel(actionKey);
switch (required) {
case 'admin':
@@ -47,7 +47,7 @@ export class TripAccessGuard implements CanActivate {
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<TripRequest>();
const user = request.user;
// JwtAuthGuard runs first and 401s an anonymous request, so a missing user here
@@ -67,7 +67,7 @@ export class TripAccessGuard implements CanActivate {
]);
if (action) {
const shared = trip.user_id !== user.id;
if (!this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared)) {
if (!(await this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared))) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@@ -51,7 +51,7 @@ export class TripOwnerGuard implements CanActivate {
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<TripRequest>();
const user = request.user;
// JwtAuthGuard runs first and 401s an anonymous request, so a missing user
+14 -14
View File
@@ -126,26 +126,26 @@ export class PlacesController {
return trip;
}
private requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): void {
if (!this.places.canEdit(trip, user)) {
private async requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): Promise<void> {
if (!(await this.places.canEdit(trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@Get()
@UseGuards(TripAccessGuard)
list(
async list(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Query('search') search?: string,
@Query('category') category?: string,
@Query('tag') tag?: string,
) {
return { places: this.places.list(tripId, { search, category, tag }) };
return { places: await this.places.list(tripId, { search, category, tag }) };
}
@Post()
create(
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: PlaceCreateDto,
@@ -155,7 +155,7 @@ export class PlacesController {
validateLengths(body);
validateRouteColor(body);
validateUrlFields(body);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
const place = this.places.create(tripId, body as never);
this.places.broadcast(tripId, 'place:created', { place }, socketId);
this.places.onCreated(tripId, place.id);
@@ -164,7 +164,7 @@ export class PlacesController {
@Post('import/gpx')
@UseInterceptors(FileInterceptor('file', UPLOAD))
importGpx(
async importGpx(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@@ -172,7 +172,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
@@ -230,7 +230,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
@@ -268,7 +268,7 @@ export class PlacesController {
/** Shared google/naver list import — identical flow, different provider + error string. */
private async importList(provider: 'google' | 'naver', user: User, tripId: string, body: PlaceImportListDto, socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
const { url, enrich } = body;
// Opt-in: re-resolve each imported place via the Places API to fill in
// photo / address / website / phone and persist a google_place_id (#886).
@@ -307,7 +307,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
return { deleted: [], count: 0 };
@@ -351,7 +351,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
const { ids } = body;
if (ids.length === 0) {
return { updated: [], count: 0 };
@@ -390,7 +390,7 @@ export class PlacesController {
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
// Inline rather than DemoWriteGuard: requireEdit above answers 404/403 for a
// trip the caller cannot reach, and a guard would run before it.
if (isDemoWriteBlocked(this.env, user.email)) {
@@ -473,7 +473,7 @@ export class PlacesController {
validateLengths(body);
validateRouteColor(body);
validateUrlFields(body);
this.requireEdit(trip, user);
await this.requireEdit(trip, user);
const result = await this.places.update(tripId, id, body as never, ifMatch);
if (!result) {
throw new HttpException({ error: 'Place not found' }, 404);
+14 -12
View File
@@ -21,6 +21,7 @@ import { AssignmentsService } from '../assignments/assignments.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { MapsService } from '../maps/maps.service';
import { PlacesService } from './places.service';
import { isDirectionsUrl } from './maps-dir.helpers';
@@ -57,6 +58,7 @@ export class PlacesMcp {
private readonly journey: JourneyDomainService,
private readonly assignments: AssignmentsService,
private readonly guards: McpToolGuardsService,
private readonly uow: UnitOfWork,
) {}
@Tool({
@@ -96,7 +98,7 @@ export class PlacesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
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 });
this.guards.safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
@@ -141,12 +143,12 @@ export class PlacesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
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 };
try {
const result = this.db.transaction(() => {
const result = await this.uow.transactional(async () => {
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: place_notes, website, phone, image_url, price, currency, stop_type });
const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
const assignment = await this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
this.guards.safeBroadcast(tripId, 'place:created', { place: result.place });
@@ -204,7 +206,7 @@ export class PlacesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
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 });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'place:updated', { place });
@@ -248,7 +250,7 @@ export class PlacesMcp {
async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
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
// id alone, so a foreign id would detach that trip's journey entries even
// though the delete below refuses it.
@@ -290,7 +292,7 @@ export class PlacesMcp {
ctx: McpContext,
) {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
const places = this.places.list(String(tripId), { search, category, tag, assignment });
const places = await this.places.list(String(tripId), { search, category, tag, assignment });
return ok({ places });
}
@@ -342,7 +344,7 @@ export class PlacesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
// Same opts the REST route builds: the enrichment pass is keyed on the calling
// user because it spends that user's Places credential.
@@ -373,7 +375,7 @@ export class PlacesMcp {
async importGpx(input: RoadtripGpxImport, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId)) return permissionDenied();
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.');
try {
const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name });
@@ -424,7 +426,7 @@ export class PlacesMcp {
async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
// Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is
// ON DELETE SET NULL, so a hook that ran afterwards found nothing left to
@@ -481,7 +483,7 @@ export class PlacesMcp {
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent };
if (Object.values(fields).every(v => v === undefined)) {
@@ -512,7 +514,7 @@ export class PlacesMcp {
};
}
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
const places = this.places.list(String(id), { assignment: assignment ?? undefined });
const places = await this.places.list(String(id), { assignment: assignment ?? undefined });
return {
contents: [{
uri: uri.href,
+4 -4
View File
@@ -52,14 +52,14 @@ export class PlacesRpc {
) {}
@PluginMethod('places.create', { permission: 'db:write:places' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'place');
const parsed = placeCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
capUrls(parsed.data as Record<string, unknown>);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
this.realtime.broadcast(tripId, 'place:created', { place });
this.mirrorJourneys(() => this.journey.onPlaceCreated(tripId, place.id));
@@ -75,7 +75,7 @@ export class PlacesRpc {
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
capUrls(parsed.data as Record<string, unknown>);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
// update is async (it may delete a superseded storage object): await it so the
// null-refusal check compares against the resolved place, not an always-truthy
// Promise, and the broadcast below carries the actual place.
@@ -91,7 +91,7 @@ export class PlacesRpc {
const tripId = num(params.tripId, 'tripId');
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'place');
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
// Scope the id to the trip before anything else: onPlaceDeleted keys on the place
// alone, so an id belonging to a foreign trip would detach THAT trip's journey
// entries even though the delete below refuses it.
+12 -10
View File
@@ -4,6 +4,7 @@ import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@t
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import type { PlaceWithTags } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
@@ -133,13 +134,14 @@ export class PlacesService {
private readonly journey: JourneyDomainService,
private readonly storage: StorageService,
private readonly accommodations: AccommodationsService,
private readonly uow: UnitOfWork,
) {}
verifyTripAccess(tripId: string, userId: number) {
return this.dbs.canAccessTrip(Number(tripId), userId);
}
canEdit(trip: Trip, user: User): boolean {
async canEdit(trip: Trip, user: User): Promise<boolean> {
return this.permissions.checkPermission('place_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
@@ -174,7 +176,7 @@ export class PlacesService {
// List places
// -------------------------------------------------------------------------
list(
async list(
tripId: string,
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
) {
@@ -217,8 +219,8 @@ export class PlacesService {
const places = this.dbs.prepare(query).all(...params) as PlaceWithCategory[];
const placeIds = places.map(p => p.id);
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds);
const ratingsByPlaceId = this.queryHelpers.loadRatingsByPlaceIds(placeIds);
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds);
const ratingsByPlaceId = await this.queryHelpers.loadRatingsByPlaceIds(placeIds);
return places.map(p => ({
...p,
@@ -449,12 +451,12 @@ export class PlacesService {
*
* Runs inside the caller's transaction.
*/
private cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): void {
private async cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): Promise<void> {
const stays = this.dbs.all<{ id: number }>(
'SELECT id FROM day_accommodations WHERE trip_id = ? AND place_id = ?', tripId, placeId,
);
for (const stay of stays) {
const gone = this.accommodations.deleteAccommodation(stay.id);
const gone = await this.accommodations.deleteAccommodation(stay.id);
// What went down with the night is what the caller has to announce. The
// partner booking and its expense are rows the Bookings list and the Costs
// total are still holding; place:deleted says nothing about either, and a
@@ -473,8 +475,8 @@ export class PlacesService {
// The linked expense goes with the place, the same way a booking takes its
// expense with it (#1298). One transaction, so a place can never survive
// half-detached from its money.
this.dbs.transaction(() => {
this.cancelStaysAt(tripId, placeId, cancelled);
await this.uow.transactional(async () => {
await this.cancelStaysAt(tripId, placeId, cancelled);
this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId);
this.dbs.run('DELETE FROM places WHERE id = ?', placeId);
});
@@ -491,11 +493,11 @@ export class PlacesService {
const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?');
const deleted: number[] = [];
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
this.dbs.transaction(() => {
await this.uow.transactional(async () => {
for (const id of ids) {
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!row) continue;
this.cancelStaysAt(tripId, id, cancelled);
await this.cancelStaysAt(tripId, id, cancelled);
deleteExpenseStmt.run(tripId, id);
deleteStmt.run(id);
deleted.push(id);
@@ -44,8 +44,8 @@ export class DiscoveryController {
// them with invalid_target — showing the user the TREK home page instead of the
// consent form.
@Get('oauth-protected-resource')
protectedResource(@Res() res: Response): void {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
async protectedResource(@Res() res: Response): Promise<void> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
res.status(404).end();
return;
}
@@ -23,12 +23,17 @@ export function createMcpMetadataMiddleware(
meta: DiscoveryMetadataService,
addons: AddonsService,
): RequestHandler {
// Express cannot await a middleware, so the now-async addon check runs in a
// helper and its rejection is handed to next() — the same error path a
// synchronous throw took before (recipe R1.5).
return (req, res, next) => {
if (req.path.startsWith('/.well-known/') && !addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(404).end();
return;
}
meta.getMetaRouter()(req, res, next);
void (async () => {
if (req.path.startsWith('/.well-known/') && !(await addons.isAddonEnabled(ADDON_IDS.MCP))) {
res.status(404).end();
return;
}
meta.getMetaRouter()(req, res, next);
})().catch(next);
};
}
@@ -78,7 +78,7 @@ export class JournalEntryRowsController {
@Param('entryId') entryIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ providers: ProviderResult[] }> {
if (!pluginsEnabled() || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] };
if (!pluginsEnabled() || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return { providers: [] };
const entryId = Number(entryIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(entryId) || userId == null) return { providers: [] };
+11 -2
View File
@@ -52,8 +52,17 @@ export function parseDependencies(raw: string | null | undefined): PluginDepende
}
/** Required addon ids that are currently NOT enabled — these block activation. */
export function disabledRequiredAddons(deps: PluginDependencies, isAddonEnabled: (id: string) => boolean): string[] {
return deps.requiredAddons.filter((a) => !isAddonEnabled(a));
export async function disabledRequiredAddons(
deps: PluginDependencies,
isAddonEnabled: (id: string) => Promise<boolean>,
): Promise<string[]> {
// A `filter` cannot await, so the same predicate runs as an explicit loop —
// same order, same result.
const disabled: string[] = [];
for (const a of deps.requiredAddons) {
if (!(await isAddonEnabled(a))) disabled.push(a);
}
return disabled;
}
export interface VersionMismatch {

Some files were not shown because too many files have changed in this diff Show More