From 769b9577ece14b4ef5c2afc5945f3392105d74ef Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 23 Sep 2026 02:46:39 +0200 Subject: [PATCH] refactor(server): trip-access primitives async at every caller --- server/src/db/database.ts | 6 +- .../accommodations/accommodations.service.ts | 4 +- .../nest/assignments/assignments.service.ts | 4 +- server/src/nest/budget/budget.mcp.ts | 4 +- server/src/nest/budget/budget.service.ts | 6 +- server/src/nest/budget/costs.rpc.ts | 2 +- server/src/nest/collab/collab.service.ts | 2 +- .../nest/collections/collections.service.ts | 12 +- server/src/nest/database/database.service.ts | 14 +- .../src/nest/day-notes/day-notes.service.ts | 2 +- server/src/nest/days/days.service.ts | 2 +- server/src/nest/feeds/feeds.mcp.ts | 2 +- server/src/nest/files/files.service.ts | 2 +- .../dawarich-suggestions.service.ts | 4 +- .../integrations/dawarich-tracks.service.ts | 2 +- .../nest/journey/journey-domain.service.ts | 2 +- .../nest/memories/memories-access.service.ts | 6 +- .../nest/memories/unified-memories.service.ts | 14 +- server/src/nest/packing/packing.service.ts | 4 +- .../src/nest/permissions/trip-access.guard.ts | 2 +- .../src/nest/permissions/trip-owner.guard.ts | 2 +- server/src/nest/places/places.mcp.ts | 24 +- server/src/nest/places/places.service.ts | 26 +- .../contributions/day-schedule.controller.ts | 2 +- .../contributions/day-tints.controller.ts | 2 +- .../contributions/map-layers.controller.ts | 2 +- .../contributions/map-markers.controller.ts | 2 +- .../contributions/pdf-sections.controller.ts | 2 +- .../contributions/place-details.controller.ts | 2 +- .../contributions/plugin-routes.controller.ts | 2 +- .../trip-card-contributions.controller.ts | 4 +- .../contributions/trip-warnings.controller.ts | 2 +- .../contributions/trip-warnings.mcp.ts | 2 +- .../view-contributions.controller.ts | 2 +- .../plugins/host/plugin-guards.service.ts | 6 +- .../nest/plugins/host/rpc/host-surface.rpc.ts | 4 +- server/src/nest/plugins/host/rpc/meta.rpc.ts | 2 +- .../src/nest/public-api/public-api.service.ts | 2 +- server/src/nest/realtime/realtime.gateway.ts | 2 +- .../reservation-import.mcp.ts | 2 +- .../nest/reservations/reservations.service.ts | 2 +- server/src/nest/roadtrip/charging.mcp.ts | 4 +- .../src/nest/roadtrip/day-boundaries.mcp.ts | 4 +- .../src/nest/roadtrip/google-route.service.ts | 2 +- .../src/nest/roadtrip/roadtrip-hazards.mcp.ts | 2 +- .../nest/roadtrip/roadtrip-plan.service.ts | 2 +- .../nest/roadtrip/roadtrip-preferences.mcp.ts | 4 +- server/src/nest/roadtrip/roadtrip.mcp.ts | 12 +- server/src/nest/share/share.service.ts | 2 +- server/src/nest/todo/todo.service.ts | 4 +- server/src/nest/transit/transit.mcp.ts | 2 +- .../nest/trip-invite/trip-invite.service.ts | 2 +- .../nest/trip-members/trip-members.service.ts | 3 +- server/src/nest/trips/trips.service.ts | 5 +- server/tests/helpers/db-mock.ts | 6 +- .../trip-access-primitives.test.ts | 328 ++++++++++++++++++ server/tests/unit/db/trip-access.test.ts | 16 +- .../nest/database-service.delegation.test.ts | 6 +- .../tests/unit/nest/database-service.test.ts | 16 +- 59 files changed, 472 insertions(+), 140 deletions(-) create mode 100644 server/tests/integration/trip-access-primitives.test.ts diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 20a6446aa..f24c17466 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -143,7 +143,7 @@ interface PlaceWithTags extends Place { rating_count: number; } -function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { +async function getPlaceWithTags(placeId: number | string): Promise { const place = db .prepare( ` @@ -202,7 +202,7 @@ interface TripAccess { currency: string | null; } -function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { +async function canAccessTrip(tripId: number | string, userId: number): Promise { return db .prepare( ` @@ -214,7 +214,7 @@ function canAccessTrip(tripId: number | string, userId: number): TripAccess | un .get(userId, tripId, userId) as TripAccess | undefined; } -function isOwner(tripId: number | string, userId: number): boolean { +async function isOwner(tripId: number | string, userId: number): Promise { return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); } diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index 31f0b78c7..bc6481a72 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -106,7 +106,7 @@ export class AccommodationsService { /** Owner or member, returning the trip. Takes a number too: the MCP tools pass * the parsed id, the REST path the raw param. */ async verifyTripAccess(tripId: string | number, userId: number) { - return this.dbs.canAccessTrip(Number(tripId), userId); + return await this.dbs.canAccessTrip(Number(tripId), userId); } async canEdit(trip: Trip, user: User): Promise { @@ -349,7 +349,7 @@ export class AccommodationsService { const place = this.db.get<{ stop_type: string | null }>('SELECT stop_type FROM places WHERE id = ?', placeId); if (!place || place.stop_type) return null; this.db.run("UPDATE places SET stop_type = 'hotel' WHERE id = ?", placeId); - return this.db.getPlaceWithTags(placeId); + return await this.db.getPlaceWithTags(placeId); } /** diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 581fe644e..bff24bfa1 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -69,7 +69,7 @@ export class AssignmentsService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.dbs.canAccessTrip(Number(tripId), userId); + return await this.dbs.canAccessTrip(Number(tripId), userId); } async canEdit(trip: Trip, user: User): Promise { @@ -426,7 +426,7 @@ export class AssignmentsService { * the request would strand a trip whose membership changed underneath it. */ async setParticipants(assignmentId: string | number, userIds: number[], tripId: string | number) { - const roster = this.dbs.rosterUserIds(tripId); + const roster = await this.dbs.rosterUserIds(tripId); const scoped = userIds.filter(id => roster.has(id)); await this.uow.transactional(async () => { this.dbs.run('DELETE FROM assignment_participants WHERE assignment_id = ?', assignmentId); diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 5cf02cc41..be9d7af15 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -124,7 +124,7 @@ export class BudgetMcp { // here instead would certify a split against a figure the row never receives. // Negative payers count with their sign (#2176) — the write path stores them. if (payers !== undefined) { - const roster = this.db.rosterUserIds(tripId); + const roster = await this.db.rosterUserIds(tripId); return sumCents(payers.filter(p => p.amount !== 0 && roster.has(p.user_id)).map(p => p.amount)); } if (total_price !== undefined) return toCents(total_price); @@ -146,7 +146,7 @@ export class BudgetMcp { totalCents: number, payers?: { user_id: number; amount: number }[], ): Promise { - const roster = this.db.rosterUserIds(tripId); + const roster = await this.db.rosterUserIds(tripId); const strangers = members.filter(m => !roster.has(m.user_id)).map(m => m.user_id); if (strangers.length > 0) { return `members contains user IDs that are not on this trip: ${strangers.join(', ')}. Resolve them with list_trip_members.`; diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 6de87b21e..67c6277a1 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -117,7 +117,7 @@ export class BudgetService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } async canEdit(trip: Trip, user: User): Promise { @@ -188,7 +188,7 @@ export class BudgetService { private async rosterMemberIds(tripId: string | number, userIds: number[]): Promise> { const unique = new Set(userIds); if (unique.size === 0) return new Set(); - const roster = this.db.rosterUserIds(tripId); + const roster = await this.db.rosterUserIds(tripId); return new Set([...unique].filter(id => roster.has(id))); } @@ -1276,7 +1276,7 @@ export class BudgetService { * an id it rejected exists at all. */ private async settlementPartiesOnTrip(tripId: string | number, data: { from_user_id: number; to_user_id: number }): Promise { - const roster = this.db.rosterUserIds(tripId); + const roster = await this.db.rosterUserIds(tripId); return roster.has(data.from_user_id) && roster.has(data.to_user_id); } diff --git a/server/src/nest/budget/costs.rpc.ts b/server/src/nest/budget/costs.rpc.ts index a7a3cf97d..ea256373c 100644 --- a/server/src/nest/budget/costs.rpc.ts +++ b/server/src/nest/budget/costs.rpc.ts @@ -120,7 +120,7 @@ export class CostsRpc { /** Trip access plus budget_edit, with the cost-specific refusal message. */ private async requireCostEdit(tripId: number, userId: number): Promise { - if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`); + if (!(await this.db.canAccessTrip(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`); if (!(await this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId))) { throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`); } diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index 7aa6c5907..990685a4c 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -147,7 +147,7 @@ export class CollabService { private readonly inFlight = new Map>(); async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } async canEdit(trip: Trip, user: User): Promise { diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index eb1dadc0c..3fb22488f 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -851,7 +851,7 @@ export class CollectionsService { // (mirrors the canAccessTrip gate in saveFromTripPlace). if ( body.source_place_id && body.source_trip_id && - this.db.canAccessTrip(body.source_trip_id, userId) && + (await this.db.canAccessTrip(body.source_trip_id, userId)) && this.db.get('SELECT 1 FROM places WHERE id = ? AND trip_id = ?', body.source_place_id, body.source_trip_id) ) { await this.copyTripRatings(body.source_place_id, id, body.collection_id); @@ -866,7 +866,7 @@ export class CollectionsService { userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean, socketId?: string, ): Promise { await this.assertCanEdit(userId, collectionId); - if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + if (!(await this.db.canAccessTrip(tripId, userId))) httpError(404, 'Trip not found'); const place = this.db.get>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); if (!place) httpError(404, 'Place not found'); @@ -904,7 +904,7 @@ export class CollectionsService { * what this import exists for, so the dialog pre-selects them. */ async importablePlaces(userId: number, collectionId: number, tripId: number): Promise { await this.assertCanEdit(userId, collectionId); - if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + if (!(await this.db.canAccessTrip(tripId, userId))) httpError(404, 'Trip not found'); // One row per place: a place can sit on several days, so the day columns resolve to the // earliest one rather than multiplying the place out across its assignments. @@ -947,7 +947,7 @@ export class CollectionsService { userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean, socketId?: string, ): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { await this.assertCanEdit(userId, collectionId); - if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + if (!(await this.db.canAccessTrip(tripId, userId))) httpError(404, 'Trip not found'); const ownerId = await this.ownerOf(collectionId); const insert = this.db.prepare(` @@ -1164,7 +1164,7 @@ export class CollectionsService { status: CollectionStatus, socketId?: string, ): Promise<{ updated: number; places: number }> { - if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + if (!(await this.db.canAccessTrip(tripId, userId))) httpError(404, 'Trip not found'); const sources = placeIds.length ? this.db.all<{ id: number; name: string; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }>(` @@ -1238,7 +1238,7 @@ export class CollectionsService { // ------------------------------------------------------------------------- async copyToTrip(userId: number, body: CollectionCopyToTripRequest): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { - const trip = this.db.canAccessTrip(body.trip_id, userId); + const trip = await 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 (!(await this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId))) { diff --git a/server/src/nest/database/database.service.ts b/server/src/nest/database/database.service.ts index 53568f1a5..88dad1fc2 100644 --- a/server/src/nest/database/database.service.ts +++ b/server/src/nest/database/database.service.ts @@ -43,12 +43,12 @@ export class DatabaseService { // must keep flowing through here. /** Trip visible to the user (owner or member); undefined when no access. */ - canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { - return canAccessTrip(tripId, userId); + async canAccessTrip(tripId: number | string, userId: number): Promise { + return await canAccessTrip(tripId, userId); } - isOwner(tripId: number | string, userId: number): boolean { - return isOwner(tripId, userId); + async isOwner(tripId: number | string, userId: number): Promise { + return await isOwner(tripId, userId); } /** @@ -62,7 +62,7 @@ export class DatabaseService { * id you name belongs to it. Runs on the connection rather than delegating to * db/database, because it has no legacy export to stay parity with. */ - rosterUserIds(tripId: number | string): Set { + async rosterUserIds(tripId: number | string): Promise> { const rows = this.all<{ user_id: number }>( 'SELECT user_id FROM trip_members WHERE trip_id = ? UNION SELECT user_id FROM trips WHERE id = ?', tripId, tripId, @@ -70,7 +70,7 @@ export class DatabaseService { return new Set(rows.map(r => r.user_id)); } - getPlaceWithTags(placeId: number | string): PlaceWithTags | null { - return getPlaceWithTags(placeId); + async getPlaceWithTags(placeId: number | string): Promise { + return await getPlaceWithTags(placeId); } } diff --git a/server/src/nest/day-notes/day-notes.service.ts b/server/src/nest/day-notes/day-notes.service.ts index 4e174f340..c448da025 100644 --- a/server/src/nest/day-notes/day-notes.service.ts +++ b/server/src/nest/day-notes/day-notes.service.ts @@ -34,7 +34,7 @@ export class DayNotesService { ) {} async verifyTripAccess(tripId: string | number, userId: number): Promise { - return this.dbs.canAccessTrip(tripId, userId); + return await this.dbs.canAccessTrip(tripId, userId); } async canEdit(trip: TripAccess, user: User): Promise { diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 584707637..bd8fdea4a 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -71,7 +71,7 @@ export class DaysService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(Number(tripId), userId); + return await this.db.canAccessTrip(Number(tripId), userId); } async canEdit(trip: Trip, user: User): Promise { diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts index 2302e259e..f859a1339 100644 --- a/server/src/nest/feeds/feeds.mcp.ts +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -58,7 +58,7 @@ export class FeedsMcp { /** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */ private async denyTripFeed(tripId: number, userId: number) { - if (!this.db.canAccessTrip(tripId, userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, userId))) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied(); return null; } diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 680fa7b59..4d1e61c07 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -89,7 +89,7 @@ export class FilesService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } async can(action: FilePermission, trip: Trip, user: User): Promise { diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index 8b939a5b3..3b2651764 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -212,7 +212,7 @@ export class DawarichSuggestionsService { const tripId = body.tripId ?? row.trip_id; if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400); - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) throw new AcceptError('not_found', 'Trip not found', 404); // Being on the roster is not permission to write to it. Every other way of @@ -260,7 +260,7 @@ export class DawarichSuggestionsService { // 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 // who accepted it. - return { created: this.db.getPlaceWithTags(id) ?? place, placeId: id, assignment: day }; + return { created: (await this.db.getPlaceWithTags(id)) ?? place, placeId: id, assignment: day }; }); // Outside the transaction, because both reach past the database: a diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts index 4543986d8..840ff9875 100644 --- a/server/src/nest/integrations/dawarich-tracks.service.ts +++ b/server/src/nest/integrations/dawarich-tracks.service.ts @@ -61,7 +61,7 @@ export class DawarichTracksService { to?: string, offsetMinutes = 0, ): Promise { - if (!this.db.canAccessTrip(tripId, userId)) return null; + if (!(await this.db.canAccessTrip(tripId, userId))) return null; const trip = this.db.get<{ start_date: string | null; end_date: string | null }>( 'SELECT start_date, end_date FROM trips WHERE id = ?', diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 123341a2b..e88d578cd 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -441,7 +441,7 @@ export class JourneyDomainService { // owner could pull an arbitrary trip's places + photos into their journey // (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped // path enforces. - if (!this.db.canAccessTrip(tripId, userId)) return false; + if (!(await this.db.canAccessTrip(tripId, userId))) return false; // And a journey the caller can actually reach. Without this, any logged-in user // could link a trip of theirs into a stranger's journey and seed entries and // photos there — the MCP tool has always checked this, the REST route never did. diff --git a/server/src/nest/memories/memories-access.service.ts b/server/src/nest/memories/memories-access.service.ts index a99a3bee1..e198c532f 100644 --- a/server/src/nest/memories/memories-access.service.ts +++ b/server/src/nest/memories/memories-access.service.ts @@ -58,7 +58,7 @@ export class MemoriesAccessService { if (!sharedAsset) { return false; } - return !!this.db.canAccessTrip(tripId, requestingUserId); + return !!(await this.db.canAccessTrip(tripId, requestingUserId)); } // ── Unified photo access check (trek_photos based) ────────────────────── @@ -108,7 +108,7 @@ export class MemoriesAccessService { // ── Album link syncing ────────────────────────────────────────────────── async getAlbumIdFromLink(tripId: string, linkId: string, userId: number): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) return fail('Trip not found or access denied', 404); try { @@ -124,7 +124,7 @@ export class MemoriesAccessService { } async getAlbumLinkForSync(tripId: string, linkId: string, userId: number): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) return fail('Trip not found or access denied', 404); try { diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts index e298c167d..45894361a 100644 --- a/server/src/nest/memories/unified-memories.service.ts +++ b/server/src/nest/memories/unified-memories.service.ts @@ -63,7 +63,7 @@ export class UnifiedMemoriesService { async listTripPhotos(tripId: string, userId: number): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -95,7 +95,7 @@ export class UnifiedMemoriesService { } async listTripAlbumLinks(tripId: string, userId: number): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -160,7 +160,7 @@ export class UnifiedMemoriesService { sid: string, albumLinkId?: string, ): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -201,7 +201,7 @@ export class UnifiedMemoriesService { shared: boolean, sid?: string, ): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -229,7 +229,7 @@ export class UnifiedMemoriesService { photoId: number, sid?: string, ): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -255,7 +255,7 @@ export class UnifiedMemoriesService { // managing album links in trip async createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } @@ -294,7 +294,7 @@ export class UnifiedMemoriesService { } async removeAlbumLink(tripId: string, linkId: string, userId: number): Promise> { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); } diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 21914ec54..da578a2d2 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -65,7 +65,7 @@ export class PackingService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } /** Mirrors the inline checkPermission('packing_edit', ...) the legacy route runs. */ @@ -586,7 +586,7 @@ export class PackingService { * grew up without it. */ private async tripRosterIds(tripId: string | number): Promise> { - return this.db.rosterUserIds(tripId); + return await this.db.rosterUserIds(tripId); } async setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { diff --git a/server/src/nest/permissions/trip-access.guard.ts b/server/src/nest/permissions/trip-access.guard.ts index 5b4aad2fb..6ac6b283a 100644 --- a/server/src/nest/permissions/trip-access.guard.ts +++ b/server/src/nest/permissions/trip-access.guard.ts @@ -56,7 +56,7 @@ export class TripAccessGuard implements CanActivate { if (!user) throw new HttpException({ error: 'Unauthorized' }, 401); const tripId = Number((request.params as Record)?.tripId); - const trip = Number.isFinite(tripId) ? this.db.canAccessTrip(tripId, user.id) : undefined; + const trip = Number.isFinite(tripId) ? await this.db.canAccessTrip(tripId, user.id) : undefined; // A trip the user may not see is reported as absent, never as forbidden: a 403 // would confirm the id exists to someone who has no business knowing. if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); diff --git a/server/src/nest/permissions/trip-owner.guard.ts b/server/src/nest/permissions/trip-owner.guard.ts index 9624a9652..90333c8bf 100644 --- a/server/src/nest/permissions/trip-owner.guard.ts +++ b/server/src/nest/permissions/trip-owner.guard.ts @@ -70,7 +70,7 @@ export class TripOwnerGuard implements CanActivate { let trip = request[TRIP_REQUEST_KEY]; if (!trip) { const tripId = Number((request.params as Record)?.[meta?.param ?? 'tripId']); - trip = Number.isFinite(tripId) ? this.db.canAccessTrip(tripId, user.id) ?? undefined : undefined; + trip = Number.isFinite(tripId) ? (await this.db.canAccessTrip(tripId, user.id)) ?? undefined : undefined; if (trip) request[TRIP_REQUEST_KEY] = trip; } if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 1fa345c34..35fcecf2f 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -97,7 +97,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = await this.places.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 }); @@ -142,7 +142,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.assignments.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; try { @@ -205,7 +205,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; @@ -230,7 +230,7 @@ export class PlacesMcp { ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Rating is a personal vote — any trip member may cast one, place_edit not required. - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); const place = await this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:updated', { place }); @@ -249,7 +249,7 @@ export class PlacesMcp { }) async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); // Scope the id to the trip before the hook: onPlaceDeleted keys on the place // id alone, so a foreign id would detach that trip's journey entries even @@ -291,7 +291,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); const places = await this.places.list(String(tripId), { search, category, tag, assignment }); return ok({ places }); } @@ -343,7 +343,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); 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 @@ -374,7 +374,7 @@ export class PlacesMcp { }) async importGpx(input: RoadtripGpxImport, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(input.tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.'); try { @@ -406,7 +406,7 @@ export class PlacesMcp { ctx: McpContext, ) { // A read, like the REST route: seeing the trip is enough, no place_edit. - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!waypoints && !tracks && !dayRoutes) return errorResult('No export types selected.'); const result = await this.places.exportGpx(String(tripId), { waypoints, tracks, dayRoutes }); if (!result) return errorResult('Nothing to export.'); @@ -425,7 +425,7 @@ export class PlacesMcp { }) async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); 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 @@ -482,7 +482,7 @@ export class PlacesMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); 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 }; @@ -504,7 +504,7 @@ export class PlacesMcp { }) async tripPlacesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.db.canAccessTrip(id, ctx.userId)) { + if (id === null || !(await this.db.canAccessTrip(id, ctx.userId))) { return { contents: [{ uri: uri.href, diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index c70ee4de2..e66315784 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -138,7 +138,7 @@ export class PlacesService { ) {} async verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(Number(tripId), userId); + return await this.dbs.canAccessTrip(Number(tripId), userId); } async canEdit(trip: Trip, user: User): Promise { @@ -160,7 +160,7 @@ export class PlacesService { private async tagsOnTrip(tripId: string | number, tagIds: number[]): Promise { const unique = [...new Set(tagIds)]; if (unique.length === 0) return []; - const roster = this.dbs.rosterUserIds(tripId); + const roster = await this.dbs.rosterUserIds(tripId); const owned = this.dbs.all<{ id: number; user_id: number }>( `SELECT id, user_id FROM tags WHERE id IN (${unique.map(() => '?').join(',')})`, ...unique, @@ -283,7 +283,7 @@ export class PlacesService { } } - return this.dbs.getPlaceWithTags(Number(placeId))!; + return (await this.dbs.getPlaceWithTags(Number(placeId)))!; } // ------------------------------------------------------------------------- @@ -293,7 +293,7 @@ export class PlacesService { async get(tripId: string, placeId: string) { const placeCheck = this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); if (!placeCheck) return null; - return this.dbs.getPlaceWithTags(placeId); + return await this.dbs.getPlaceWithTags(placeId); } // ------------------------------------------------------------------------- @@ -329,7 +329,7 @@ export class PlacesService { // edit on and the row has moved on since, reject instead of clobbering. Absent // token => unconditional update (back-compat — old clients keep last-write-wins). if (ifMatch !== undefined && existingPlace.updated_at != null && String(existingPlace.updated_at) !== ifMatch) { - return { result: { conflict: true, server: this.dbs.getPlaceWithTags(placeId) } }; + return { result: { conflict: true, server: await this.dbs.getPlaceWithTags(placeId) } }; } const { @@ -418,7 +418,7 @@ export class PlacesService { ? existingPlace.image_url : undefined; - return { result: this.dbs.getPlaceWithTags(placeId), reclaim }; + return { result: await this.dbs.getPlaceWithTags(placeId), reclaim }; } // ------------------------------------------------------------------------- @@ -803,7 +803,7 @@ export class PlacesService { continue; } const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); - const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; created.push(place); trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); } @@ -916,7 +916,7 @@ export class PlacesService { parsedPlacemark.routeGeometry, ); - const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; created.push(place); trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); summary.createdCount += 1; @@ -1147,7 +1147,7 @@ export class PlacesService { continue; } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); - const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; created.push(place); trackInsertedInDedupSet(candidate, dedup); } @@ -1387,7 +1387,7 @@ export class PlacesService { continue; } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); - const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; + const place = (await this.dbs.getPlaceWithTags(Number(result.lastInsertRowid)))!; created.push(place); trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } @@ -1475,7 +1475,7 @@ export class PlacesService { // Push the enriched row to every connected client (no socket exclusion: the // importer's own client should also receive the late update). - const updated = this.dbs.getPlaceWithTags(place.id); + const updated = await this.dbs.getPlaceWithTags(place.id); if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined); } @@ -1546,7 +1546,7 @@ export class PlacesService { 'UPDATE places SET address = COALESCE(address, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?', address, place.id, tripId, ); - const updated = this.dbs.getPlaceWithTags(place.id); + const updated = await this.dbs.getPlaceWithTags(place.id); if (updated) this.realtime.broadcast(tripId, 'place:updated', { place: updated }, undefined); } catch (err) { console.error(`[Places] address backfill failed for place ${place.id}:`, err instanceof Error ? err.message : err); @@ -1589,7 +1589,7 @@ export class PlacesService { ON CONFLICT(place_id, user_id) DO UPDATE SET rating = excluded.rating `, placeId, userId, rating); } - return this.dbs.getPlaceWithTags(placeId); + return await this.dbs.getPlaceWithTags(placeId); } // Journey hooks — non-fatal, mirroring the route's try/catch wrappers. diff --git a/server/src/nest/plugins/contributions/day-schedule.controller.ts b/server/src/nest/plugins/contributions/day-schedule.controller.ts index bd7319375..dfe3c1689 100644 --- a/server/src/nest/plugins/contributions/day-schedule.controller.ts +++ b/server/src/nest/plugins/contributions/day-schedule.controller.ts @@ -91,7 +91,7 @@ export class DayScheduleController { if (!pluginsEnabled()) return { items: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { items: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { items: [] }; const ids = this.hooks.providersOf('dayScheduleProvider'); if (ids.length === 0) return { items: [] }; diff --git a/server/src/nest/plugins/contributions/day-tints.controller.ts b/server/src/nest/plugins/contributions/day-tints.controller.ts index b6c76652f..e8073f7d1 100644 --- a/server/src/nest/plugins/contributions/day-tints.controller.ts +++ b/server/src/nest/plugins/contributions/day-tints.controller.ts @@ -168,7 +168,7 @@ export class DayTintsController { if (!pluginsEnabled()) return { tints: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { tints: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { tints: [] }; const ids = this.hooks.providersOf('dayTintProvider'); if (ids.length === 0) return { tints: [] }; diff --git a/server/src/nest/plugins/contributions/map-layers.controller.ts b/server/src/nest/plugins/contributions/map-layers.controller.ts index 53bf030c9..f5b026150 100644 --- a/server/src/nest/plugins/contributions/map-layers.controller.ts +++ b/server/src/nest/plugins/contributions/map-layers.controller.ts @@ -158,7 +158,7 @@ export class MapLayersController { if (!pluginsEnabled()) return { layers: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { layers: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { layers: [] }; const ids = this.hooks.providersOf('mapLayerProvider'); const perProvider = await Promise.all( diff --git a/server/src/nest/plugins/contributions/map-markers.controller.ts b/server/src/nest/plugins/contributions/map-markers.controller.ts index 2dcee2ae8..f5a676de5 100644 --- a/server/src/nest/plugins/contributions/map-markers.controller.ts +++ b/server/src/nest/plugins/contributions/map-markers.controller.ts @@ -91,7 +91,7 @@ export class MapMarkersController { if (!pluginsEnabled()) return { markers: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { markers: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { markers: [] }; const ids = this.hooks.providersOf('mapMarkerProvider'); const perProvider = await Promise.all( diff --git a/server/src/nest/plugins/contributions/pdf-sections.controller.ts b/server/src/nest/plugins/contributions/pdf-sections.controller.ts index 72181e2be..ec2f3f0ce 100644 --- a/server/src/nest/plugins/contributions/pdf-sections.controller.ts +++ b/server/src/nest/plugins/contributions/pdf-sections.controller.ts @@ -87,7 +87,7 @@ export class PdfSectionsController { if (!pluginsEnabled()) return { sections: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { sections: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { sections: [] }; const ids = this.hooks.providersOf('pdfSectionProvider'); const perProvider = await Promise.all( diff --git a/server/src/nest/plugins/contributions/place-details.controller.ts b/server/src/nest/plugins/contributions/place-details.controller.ts index a042ab1cc..a81769370 100644 --- a/server/src/nest/plugins/contributions/place-details.controller.ts +++ b/server/src/nest/plugins/contributions/place-details.controller.ts @@ -78,7 +78,7 @@ export class PlaceDetailsController { // The place must belong to a trip the caller can access — same gate as a read. const row = this.dbs.connection.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined; - if (!row || !this.dbs.canAccessTrip(row.trip_id, userId)) return { providers: [] }; + if (!row || !(await this.dbs.canAccessTrip(row.trip_id, userId))) return { providers: [] }; const ids = this.hooks.providersOf('placeDetailProvider'); const results = await Promise.all( diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index 25e31dba1..44b15bf14 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -45,7 +45,7 @@ export class PluginRoutesController { if (!pluginsEnabled()) return { route: null }; const userId = req.user?.id; const tripId = Number(body?.tripId); - if (userId == null || !Number.isFinite(tripId) || !this.dbs.canAccessTrip(tripId, userId)) return { route: null }; + if (userId == null || !Number.isFinite(tripId) || !(await this.dbs.canAccessTrip(tripId, userId))) return { route: null }; if (!PROFILE_RE.test(profileId)) return { route: null }; const waypoints = readWaypoints(body?.waypoints); if (!waypoints) return { route: null }; diff --git a/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts b/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts index e876df908..4eacd9d3b 100644 --- a/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts +++ b/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts @@ -103,7 +103,9 @@ export class TripCardContributionsController { .map((s) => Number(s.trim())) .filter((n) => Number.isInteger(n) && n > 0) .slice(0, MAX_TRIP_IDS); - const accessible = [...new Set(requested)].filter((id) => this.dbs.canAccessTrip(id, userId)); + const uniqueRequested = [...new Set(requested)]; + const accessFlags = await Promise.all(uniqueRequested.map((id) => this.dbs.canAccessTrip(id, userId))); + const accessible = uniqueRequested.filter((_, i) => accessFlags[i]); if (accessible.length === 0) return { contributions: [] }; const allowed = new Set(accessible); diff --git a/server/src/nest/plugins/contributions/trip-warnings.controller.ts b/server/src/nest/plugins/contributions/trip-warnings.controller.ts index 90ea875ab..99cef1439 100644 --- a/server/src/nest/plugins/contributions/trip-warnings.controller.ts +++ b/server/src/nest/plugins/contributions/trip-warnings.controller.ts @@ -40,7 +40,7 @@ export class TripWarningsController { if (!pluginsEnabled()) return { warnings: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { warnings: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { warnings: [] }; const ids = this.hooks.providersOf('warningProvider'); const perProvider = await Promise.all( diff --git a/server/src/nest/plugins/contributions/trip-warnings.mcp.ts b/server/src/nest/plugins/contributions/trip-warnings.mcp.ts index 6316eb1b8..fd33b41b3 100644 --- a/server/src/nest/plugins/contributions/trip-warnings.mcp.ts +++ b/server/src/nest/plugins/contributions/trip-warnings.mcp.ts @@ -55,7 +55,7 @@ export class TripWarningsMcp { async getTripWarnings({ tripId }: { tripId: number }, ctx: McpContext): Promise { // Access first, so the answer to "may I look at this trip" does not depend on // whether an admin has the plugin system switched on. - if (!this.dbs.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.dbs.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!pluginsEnabled()) return ok({ warnings: [] }); const ids = this.hooks.providersOf('warningProvider'); diff --git a/server/src/nest/plugins/contributions/view-contributions.controller.ts b/server/src/nest/plugins/contributions/view-contributions.controller.ts index ea22d7aca..80de4f770 100644 --- a/server/src/nest/plugins/contributions/view-contributions.controller.ts +++ b/server/src/nest/plugins/contributions/view-contributions.controller.ts @@ -116,7 +116,7 @@ export class ViewContributionsController { if (!pluginsEnabled() || !VIEWS.has(view)) return { contributions: [] }; const tripId = Number(tripIdRaw); const userId = req.user?.id; - if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { contributions: [] }; + if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { contributions: [] }; const ids = this.hooks.providersOf('tableContributor'); const perProvider = await Promise.all( diff --git a/server/src/nest/plugins/host/plugin-guards.service.ts b/server/src/nest/plugins/host/plugin-guards.service.ts index e55a57b9d..274b6fccd 100644 --- a/server/src/nest/plugins/host/plugin-guards.service.ts +++ b/server/src/nest/plugins/host/plugin-guards.service.ts @@ -36,7 +36,7 @@ export class PluginGuards { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('trip reads require an authenticated user context'); } - if (!this.db.canAccessTrip(tripId, ctx.actingUserId)) { + if (!(await this.db.canAccessTrip(tripId, ctx.actingUserId))) { throw new ForbiddenResource(`no access to trip ${tripId}`); } // The read runs only for a bound, membership-checked user, so hand the id through @@ -57,7 +57,7 @@ export class PluginGuards { /** A write is allowed only if the acting user can access AND edit the trip. */ async requireTripEdit(tripId: number, userId: number, action: string): Promise { - if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`); + if (!(await this.db.canAccessTrip(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`); if (!(await this.canEditAs(action, tripId, userId))) throw new ForbiddenResource(`no permission to edit trip ${tripId}`); } @@ -67,7 +67,7 @@ export class PluginGuards { * message the refusal carries. */ async canEditAs(action: string, tripId: number, userId: number): Promise { - const trip = this.db.canAccessTrip(tripId, userId); + const trip = await this.db.canAccessTrip(tripId, userId); if (!trip) return false; const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; if (!user) return false; diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index f4c0eebc6..564c2b0e3 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -62,7 +62,7 @@ export class HostSurfaceRpc { // Namespacing the event type alone does not cross the membership boundary. const tripId = num(params.tripId, 'tripId'); if (ctx.actingUserId === undefined) throw new ForbiddenResource('broadcasts require an authenticated user context'); - if (!this.db.canAccessTrip(tripId, ctx.actingUserId)) throw new ForbiddenResource(`no access to trip ${tripId}`); + if (!(await this.db.canAccessTrip(tripId, ctx.actingUserId))) throw new ForbiddenResource(`no access to trip ${tripId}`); // The host forces the plugin:{id}:{event} namespace, so a plugin cannot forge a // core event. this.realtime.broadcast(tripId, `plugin:${ctx.pluginId}:${str(params.event, 'event')}`, asPayload(params.data)); @@ -101,7 +101,7 @@ export class HostSurfaceRpc { if (scope !== 'user' && scope !== 'trip') throw new BadParams("scope must be 'user' or 'trip'"); const targetId = num(input.targetId, 'targetId'); if (scope === 'user' && targetId !== actor) throw new ForbiddenResource('a plugin may only notify the acting user'); - if (scope === 'trip' && !this.db.canAccessTrip(targetId, actor)) { + if (scope === 'trip' && !(await this.db.canAccessTrip(targetId, actor))) { throw new ForbiddenResource('the acting user is not a member of that trip'); } const link = this.safeLink(input.link); diff --git a/server/src/nest/plugins/host/rpc/meta.rpc.ts b/server/src/nest/plugins/host/rpc/meta.rpc.ts index 43969eb14..a92cb28f8 100644 --- a/server/src/nest/plugins/host/rpc/meta.rpc.ts +++ b/server/src/nest/plugins/host/rpc/meta.rpc.ts @@ -130,7 +130,7 @@ export class MetaRpc { const entityId = num(params.entityId, 'entityId'); if (ctx.actingUserId === undefined) throw new ForbiddenResource('metadata requires an authenticated user context'); const tripId = await this.entityTrip(entityType, entityId); - if (tripId === undefined || !this.db.canAccessTrip(tripId, ctx.actingUserId)) { + if (tripId === undefined || !(await this.db.canAccessTrip(tripId, ctx.actingUserId))) { throw new ForbiddenResource(`no access to ${entityType} ${entityId}`); } if (write && !(await this.guards.canEditAs(EDIT_ACTION[entityType], tripId, ctx.actingUserId))) { diff --git a/server/src/nest/public-api/public-api.service.ts b/server/src/nest/public-api/public-api.service.ts index d9223e067..54ba3b47d 100644 --- a/server/src/nest/public-api/public-api.service.ts +++ b/server/src/nest/public-api/public-api.service.ts @@ -62,7 +62,7 @@ export class PublicApiService { * trip ids exist. */ async getTrip(tripId: number, userId: number, include: PublicApiInclude[], granted: readonly string[] = include): Promise { - if (!this.db.canAccessTrip(tripId, userId)) return null; + if (!(await this.db.canAccessTrip(tripId, userId))) return null; const row = this.db.get( `SELECT id, title, description, start_date, end_date, currency, is_archived, updated_at FROM trips WHERE id = ?`, diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index 08ca69330..8e215f44f 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -170,7 +170,7 @@ export class RealtimeGateway if (!user || !message?.tripId) return undefined; const tripId = Number(message.tripId); - if (!this.db.canAccessTrip(tripId, user.id)) { + if (!(await this.db.canAccessTrip(tripId, user.id))) { return { type: 'error', message: 'Access denied' }; } joinRoom(socket, tripId); diff --git a/server/src/nest/reservation-import/reservation-import.mcp.ts b/server/src/nest/reservation-import/reservation-import.mcp.ts index 827d01109..9712556fe 100644 --- a/server/src/nest/reservation-import/reservation-import.mcp.ts +++ b/server/src/nest/reservation-import/reservation-import.mcp.ts @@ -70,7 +70,7 @@ export class ReservationImportMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (flightIds.length > MAX_MCP_AIRTRAIL_FLIGHTS) { diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 0c069d545..79913c113 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -156,7 +156,7 @@ export class ReservationsService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } async canEdit(trip: Trip, user: User): Promise { diff --git a/server/src/nest/roadtrip/charging.mcp.ts b/server/src/nest/roadtrip/charging.mcp.ts index 008efc9b0..fd5f36759 100644 --- a/server/src/nest/roadtrip/charging.mcp.ts +++ b/server/src/nest/roadtrip/charging.mcp.ts @@ -16,14 +16,14 @@ export class ChargingMcp { inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); return answeringRefusals(async () => ok(await this.charging.read(tripId, placeId))); } @Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.', inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); return ok(await this.charging.lookup(lat, lng, name)); } } diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts index ffd569f20..beeff725d 100644 --- a/server/src/nest/roadtrip/day-boundaries.mcp.ts +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -31,7 +31,7 @@ export class DayBoundariesMcp { annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when, }) async list({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); return ok({ boundaries: await this.boundaries.list(tripId) }); } @@ -43,7 +43,7 @@ export class DayBoundariesMcp { }) async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A stop from another trip is refused by the service, with the reason. return answeringRefusals(async () => { diff --git a/server/src/nest/roadtrip/google-route.service.ts b/server/src/nest/roadtrip/google-route.service.ts index 2ded31c48..b17df7f60 100644 --- a/server/src/nest/roadtrip/google-route.service.ts +++ b/server/src/nest/roadtrip/google-route.service.ts @@ -56,7 +56,7 @@ export class GoogleRouteService { } async import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) { - const access = this.db.canAccessTrip(tripId, userId); + const access = await this.db.canAccessTrip(tripId, userId); if (!access) throw new HttpException({ error: 'Trip not found' }, 404); const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; // `every` cannot await the permission check, so the same all-of test runs as diff --git a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts index 58a5c8fc2..b486d7ae9 100644 --- a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts @@ -17,7 +17,7 @@ export class RoadtripHazardsMcp { annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP), }) async read({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); return ok(await this.hazards.read()); } } diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 152220f2e..33fb739f4 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -69,7 +69,7 @@ export class RoadtripPlanService { ) {} async context(tripId: number, userId: number) { - if (!this.db.canAccessTrip(tripId, userId)) throw new HttpException({ error: 'Trip not found' }, 404); + if (!(await this.db.canAccessTrip(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404); const days = this.db.all( 'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number', tripId, diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts index 097085112..0c3024abb 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -42,7 +42,7 @@ export class RoadtripPreferencesMcp { when, }) async read({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); return ok({ tripId, settings: await this.preferences.read(tripId), scope: 'trip' }); } @@ -57,7 +57,7 @@ export class RoadtripPreferencesMcp { }) async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A day window that ends before it starts is refused by the service, with the reason. return answeringRefusals(async () => ok({ tripId, settings: await this.preferences.update(tripId, settings), scope: 'trip' })); diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index f833782bc..5cb36ee31 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -48,7 +48,7 @@ export class RoadtripMcp { when: roadtripAddonOn, }) async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) { - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (dayId != null) { if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); return ok({ vias: await this.roadtrip.listForDay(dayId) }); @@ -75,7 +75,7 @@ export class RoadtripMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); const via = await this.roadtrip.create(dayId, { after_order_index, lat, lng }); @@ -117,7 +117,7 @@ export class RoadtripMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); // The same check the REST route makes, in the same change: permission alone would let @@ -156,7 +156,7 @@ export class RoadtripMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); const next = await this.roadtrip.reanchor(dayId, { vias, remove }); @@ -178,7 +178,7 @@ export class RoadtripMcp { }) async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); if (!(await this.roadtrip.remove(viaId, dayId))) return noAccess(); @@ -194,7 +194,7 @@ export class RoadtripMcp { }) async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(input.tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess(); const via = await this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index e5d4cc66c..ccea7129a 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -160,7 +160,7 @@ export class ShareService { ) {} async verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(tripId, userId); + return await this.dbs.canAccessTrip(tripId, userId); } async canManage(trip: Trip, user: User): Promise { diff --git a/server/src/nest/todo/todo.service.ts b/server/src/nest/todo/todo.service.ts index ad467417a..6ff92eae3 100644 --- a/server/src/nest/todo/todo.service.ts +++ b/server/src/nest/todo/todo.service.ts @@ -27,7 +27,7 @@ export class TodoService { ) {} async verifyTripAccess(tripId: string | number, userId: number) { - return this.db.canAccessTrip(tripId, userId); + return await this.db.canAccessTrip(tripId, userId); } async canEdit(trip: Trip, user: User): Promise { @@ -142,7 +142,7 @@ export class TodoService { // members and reservations filter travellers. Dropped rather than // rejected: a copied trip carries assignee ids across before its members // exist, and a 400 would make the picker unusable there. - const roster = this.db.rosterUserIds(tripId); + const roster = await this.db.rosterUserIds(tripId); for (const uid of userIds) if (roster.has(uid)) insert.run(tripId, categoryName, uid); } }); diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 2453a662c..215e5d06c 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -184,7 +184,7 @@ export class TransitMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const day = await this.days.getDay(dayId, tripId); if (!day) { diff --git a/server/src/nest/trip-invite/trip-invite.service.ts b/server/src/nest/trip-invite/trip-invite.service.ts index 40130671d..7ff620c5b 100644 --- a/server/src/nest/trip-invite/trip-invite.service.ts +++ b/server/src/nest/trip-invite/trip-invite.service.ts @@ -37,7 +37,7 @@ export class TripInviteService { ) {} async verifyTripAccess(tripId: string, userId: number) { - return this.dbs.canAccessTrip(tripId, userId); + return await this.dbs.canAccessTrip(tripId, userId); } async canManage(trip: Trip, user: User): Promise { diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index f22c968b5..970f5a78c 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -75,7 +75,8 @@ export class TripMembersService { } async canAccessTrip(tripId: string | number, userId: number) { - return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; + const access = await this.dbs.canAccessTrip(tripId, userId); + return access as { user_id: number } | null | undefined; } async can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): Promise { diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 58b062f8b..190b34b11 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -168,11 +168,12 @@ export class TripsService { } async canAccessTrip(tripId: string | number, userId: number) { - return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; + const access = await this.dbs.canAccessTrip(tripId, userId); + return access as { user_id: number } | null | undefined; } async isOwner(tripId: string | number, userId: number): Promise { - return this.dbs.isOwner(tripId, userId); + return await this.dbs.isOwner(tripId, userId); } async can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): Promise { diff --git a/server/tests/helpers/db-mock.ts b/server/tests/helpers/db-mock.ts index 6cf1cc015..fce9d06ee 100644 --- a/server/tests/helpers/db-mock.ts +++ b/server/tests/helpers/db-mock.ts @@ -93,7 +93,7 @@ export function buildDbMock(testDb: Database.Database) { db: testDb, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: (placeId: number | string) => { + getPlaceWithTags: async (placeId: number | string) => { interface PlaceRow { id: number; category_id: number | null; @@ -128,10 +128,10 @@ export function buildDbMock(testDb: Database.Database) { tags, }; }, - canAccessTrip: (tripId: number | string, userId: number) => { + canAccessTrip: async (tripId: number | string, userId: number) => { return testDb.prepare(CAN_ACCESS_TRIP_SQL).get(userId, tripId, userId); }, - isOwner: (tripId: number | string, userId: number) => { + isOwner: async (tripId: number | string, userId: number) => { return !!testDb.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); }, getRawConnection: () => testDb, diff --git a/server/tests/integration/trip-access-primitives.test.ts b/server/tests/integration/trip-access-primitives.test.ts new file mode 100644 index 000000000..c4c833c5c --- /dev/null +++ b/server/tests/integration/trip-access-primitives.test.ts @@ -0,0 +1,328 @@ +/** + * Parity guards for the Plan 3c Task 0a async sweep. + * + * `canAccessTrip`, `isOwner`, `rosterUserIds` and `getPlaceWithTags` + * (`src/db/database.ts` + their `DatabaseService` delegations) went from + * synchronous to `async` in that task, with every caller updated to `await` + * them. These four tests each drive a REAL guarded route through the real + * `buildApp()` DI graph — one per primitive — as owner / member / non-member + * / anonymous, and pin the legacy status + body so a later task (0b: moving + * the bodies onto `TripsRepository`) cannot silently change behaviour. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; + +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); + +import { db as testDb } from '../../src/db/database'; +import { buildApp } from '../../src/bootstrap'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAssignment } from '../helpers/factories'; +import { authCookie, generateToken } from '../helpers/auth'; +import { closeMcpSessions } from '../../src/mcp/index'; + +let nestApp: INestApplication; +let app: Application; + +beforeAll(async () => { + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); +beforeEach(() => { + resetTestDb(testDb); + resetRateLimits(nestApp); +}); +afterAll(async () => { + closeMcpSessions(); + await nestApp.close(); + testDb.close(); +}); + +// ───────────────────────────────────────────────────────────────────────── +// canAccessTrip — TripAccessGuard, exercised by GET /api/trips/:tripId/days +// ───────────────────────────────────────────────────────────────────────── + +describe('canAccessTrip (async) — TripAccessGuard on GET /api/trips/:tripId/days', () => { + it('PRIM-CAT-001 — owner: 200 with the trip days', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { start_date: '2026-06-01', end_date: '2026-06-02' }); + + const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(owner.id)); + expect(res.status).toBe(200); + expect(res.body.days).toHaveLength(2); + }); + + it('PRIM-CAT-002 — member: 200 with the trip days', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id, { start_date: '2026-06-01', end_date: '2026-06-01' }); + addTripMember(testDb, trip.id, member.id); + + const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(member.id)); + expect(res.status).toBe(200); + expect(res.body.days).toHaveLength(1); + }); + + it('PRIM-CAT-003 — non-member: 404 { error: "Trip not found" } — never 403 (never confirms the id exists)', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(stranger.id)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('PRIM-CAT-004 — anonymous: 401', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app).get(`/api/trips/${trip.id}/days`); + expect(res.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// isOwner — the delete_trip MCP tool (its only caller anywhere) +// ───────────────────────────────────────────────────────────────────────── + +describe('isOwner (async) — the delete_trip MCP tool', () => { + async function mcpSession(userId: number): Promise { + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${generateToken(userId)}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + expect(res.status).toBe(200); + const sessionId = res.headers['mcp-session-id']; + expect(sessionId).toBeTruthy(); + return sessionId as string; + } + + function deleteTripBody(tripId: number) { + return { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'delete_trip', arguments: { tripId } } }; + } + + /** /mcp answers as SSE; the JSON-RPC payload rides a `data:` line. */ + function toolResult(text: string): { isError?: boolean; content?: { type: string; text: string }[] } { + const line = text.split('\n').find((l) => l.startsWith('data:')); + if (!line) throw new Error(`no SSE data frame in: ${text.slice(0, 200)}`); + return (JSON.parse(line.slice('data:'.length).trim()) as { result?: { isError?: boolean; content?: { type: string; text: string }[] } }).result ?? {}; + } + + beforeEach(() => { + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + }); + + it('PRIM-ISOWN-001 — owner: 200, deletes the trip', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const sessionId = await mcpSession(owner.id); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${generateToken(owner.id)}`) + .set('mcp-session-id', sessionId) + .set('Accept', 'application/json, text/event-stream') + .send(deleteTripBody(trip.id)); + + expect(res.status).toBe(200); + const result = toolResult(res.text); + expect(result.isError).toBeFalsy(); + expect(JSON.parse(result.content?.[0]?.text ?? '{}')).toEqual({ success: true, tripId: trip.id }); + expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined(); + }); + + it('PRIM-ISOWN-002 — member (not owner): refused with the same "no access" message as a stranger, trip untouched', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const sessionId = await mcpSession(member.id); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${generateToken(member.id)}`) + .set('mcp-session-id', sessionId) + .set('Accept', 'application/json, text/event-stream') + .send(deleteTripBody(trip.id)); + + expect(res.status).toBe(200); + const result = toolResult(res.text); + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); + expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + }); + + it('PRIM-ISOWN-003 — non-member/stranger: the identical refusal isOwner gives a member, trip untouched', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const sessionId = await mcpSession(stranger.id); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${generateToken(stranger.id)}`) + .set('mcp-session-id', sessionId) + .set('Accept', 'application/json, text/event-stream') + .send(deleteTripBody(trip.id)); + + expect(res.status).toBe(200); + const result = toolResult(res.text); + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); + expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + }); + + it('PRIM-ISOWN-004 — anonymous: 401, no session established', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app).post('/mcp').send(deleteTripBody(trip.id)); + expect(res.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// rosterUserIds — PUT /api/trips/:tripId/assignments/:id/participants +// ───────────────────────────────────────────────────────────────────────── + +describe('rosterUserIds (async) — PUT .../assignments/:id/participants', () => { + it('PRIM-ROSTER-001 — owner: 200, off-roster ids are dropped silently (no 400)', async () => { + const { user: owner } = createUser(testDb); + const { user: outsider } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + + const res = await request(app) + .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) + .set('Cookie', authCookie(owner.id)) + .send({ user_ids: [owner.id, outsider.id] }); + + expect(res.status).toBe(200); + expect(res.body.participants.map((p: { user_id: number }) => p.user_id).sort()).toEqual([owner.id]); + }); + + it('PRIM-ROSTER-002 — member with edit rights: 200, keeps only roster ids (owner + member)', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const { user: outsider } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + + const res = await request(app) + .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) + .set('Cookie', authCookie(member.id)) + .send({ user_ids: [owner.id, member.id, outsider.id] }); + + expect(res.status).toBe(200); + expect(res.body.participants.map((p: { user_id: number }) => p.user_id).sort((a: number, b: number) => a - b)).toEqual([owner.id, member.id].sort((a, b) => a - b)); + }); + + it('PRIM-ROSTER-003 — non-member: 404 { error: "Trip not found" }, guard refuses before rosterUserIds runs', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + + const res = await request(app) + .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) + .set('Cookie', authCookie(stranger.id)) + .send({ user_ids: [owner.id] }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('PRIM-ROSTER-004 — anonymous: 401', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + + const res = await request(app) + .put(`/api/trips/${trip.id}/assignments/${assignment.id}/participants`) + .send({ user_ids: [owner.id] }); + + expect(res.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// getPlaceWithTags — GET /api/trips/:tripId/places/:id +// ───────────────────────────────────────────────────────────────────────── + +describe('getPlaceWithTags (async) — GET /api/trips/:tripId/places/:id', () => { + it('PRIM-GPWT-001 — owner: 200 with the hydrated place (category/tags/ratings shape)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + + const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(owner.id)); + expect(res.status).toBe(200); + // The mocked db/database module's getPlaceWithTags stand-in (tests/helpers/db-mock.ts) + // only carries category + tags, not the ratings aggregate — matched here, not the + // production shape (§4 of the test-db.ts header explains the mock/real split). + expect(res.body.place).toMatchObject({ + id: place.id, + name: 'Eiffel Tower', + tags: [], + }); + expect(res.body.place).toHaveProperty('category'); + }); + + it('PRIM-GPWT-002 — member: 200 with the same hydrated place', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const place = createPlace(testDb, trip.id); + + const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(member.id)); + expect(res.status).toBe(200); + expect(res.body.place.id).toBe(place.id); + }); + + it('PRIM-GPWT-003 — non-member: 404 { error: "Trip not found" } from the guard, before getPlaceWithTags runs', async () => { + const { user: owner } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const place = createPlace(testDb, trip.id); + + const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(stranger.id)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('PRIM-GPWT-004 — anonymous: 401', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const place = createPlace(testDb, trip.id); + + const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`); + expect(res.status).toBe(401); + }); +}); diff --git a/server/tests/unit/db/trip-access.test.ts b/server/tests/unit/db/trip-access.test.ts index 6a4567e90..0ed3a76d0 100644 --- a/server/tests/unit/db/trip-access.test.ts +++ b/server/tests/unit/db/trip-access.test.ts @@ -21,17 +21,17 @@ describe('canAccessTrip', () => { db.exec('DELETE FROM trip_members; DELETE FROM trips; DELETE FROM users;'); }); - it('returns the trip currency for the owner (#1543)', () => { + it('returns the trip currency for the owner (#1543)', async () => { const owner = seedUser('owner'); const tripId = Number( db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (?, 'Trip', 'RUB')") .run(owner).lastInsertRowid, ); - expect(canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, user_id: owner, currency: 'RUB' }); + expect(await canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, user_id: owner, currency: 'RUB' }); }); - it('returns the trip currency for a member too', () => { + it('returns the trip currency for a member too', async () => { const owner = seedUser('owner2'); const member = seedUser('member2'); const tripId = Number( @@ -40,17 +40,17 @@ describe('canAccessTrip', () => { ); db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member); - expect(canAccessTrip(tripId, member)).toMatchObject({ currency: 'JPY' }); + expect(await canAccessTrip(tripId, member)).toMatchObject({ currency: 'JPY' }); }); - it('returns undefined for a user with no access', () => { + it('returns undefined for a user with no access', async () => { const owner = seedUser('owner3'); const stranger = seedUser('stranger3'); const tripId = Number( db.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'Trip')").run(owner).lastInsertRowid, ); - expect(canAccessTrip(tripId, stranger)).toBeUndefined(); + expect(await canAccessTrip(tripId, stranger)).toBeUndefined(); }); }); @@ -59,7 +59,7 @@ describe('canAccessTrip', () => { * regression the block above pins was invisible to the suites that mock the db. */ describe('the buildDbMock stand-in for canAccessTrip', () => { - it('hands back the trip currency, like the real one', () => { + it('hands back the trip currency, like the real one', async () => { const testDb = createTestDb(); try { const owner = Number( @@ -71,7 +71,7 @@ describe('the buildDbMock stand-in for canAccessTrip', () => { .run(owner).lastInsertRowid, ); - expect(buildDbMock(testDb).canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, currency: 'ISK' }); + expect(await buildDbMock(testDb).canAccessTrip(tripId, owner)).toMatchObject({ id: tripId, currency: 'ISK' }); } finally { testDb.close(); } diff --git a/server/tests/unit/nest/database-service.delegation.test.ts b/server/tests/unit/nest/database-service.delegation.test.ts index 8e1485fe5..432da36bf 100644 --- a/server/tests/unit/nest/database-service.delegation.test.ts +++ b/server/tests/unit/nest/database-service.delegation.test.ts @@ -27,13 +27,13 @@ describe('DatabaseService (helper delegation)', () => { const { db } = await import('../../../src/db/database'); const svc = new DatabaseService(db); - expect(svc.canAccessTrip(7, 8)).toEqual({ id: -1, user_id: -2, currency: 'XXX' }); + expect(await svc.canAccessTrip(7, 8)).toEqual({ id: -1, user_id: -2, currency: 'XXX' }); expect(canAccessTrip).toHaveBeenCalledWith(7, 8); - expect(svc.isOwner(7, 8)).toBe(true); + expect(await svc.isOwner(7, 8)).toBe(true); expect(isOwner).toHaveBeenCalledWith(7, 8); - expect(svc.getPlaceWithTags(9)).toBeNull(); + expect(await svc.getPlaceWithTags(9)).toBeNull(); expect(getPlaceWithTags).toHaveBeenCalledWith(9); }); }); diff --git a/server/tests/unit/nest/database-service.test.ts b/server/tests/unit/nest/database-service.test.ts index 331fa7774..667b58e76 100644 --- a/server/tests/unit/nest/database-service.test.ts +++ b/server/tests/unit/nest/database-service.test.ts @@ -36,7 +36,7 @@ describe('DatabaseService (typed query helpers)', () => { describe('DatabaseService (trip-access helpers)', () => { const svc = new DatabaseService(db); - it('canAccessTrip / isOwner / getPlaceWithTags delegate to the shared helpers', () => { + it('canAccessTrip / isOwner / getPlaceWithTags delegate to the shared helpers', async () => { svc.run( "INSERT INTO users (id, username, email, password_hash) VALUES (900, 'dbsvc-owner', 'dbsvc-owner@x.test', 'x')" ); @@ -46,16 +46,16 @@ describe('DatabaseService (trip-access helpers)', () => { svc.run("INSERT INTO trips (id, user_id, title) VALUES (900, 900, 'DbSvc Trip')"); svc.run("INSERT INTO trip_members (trip_id, user_id) VALUES (900, 901)"); - expect(svc.canAccessTrip(900, 900)).toMatchObject({ id: 900, user_id: 900 }); - expect(svc.canAccessTrip(900, 901)).toMatchObject({ id: 900 }); - expect(svc.canAccessTrip(900, 999)).toBeUndefined(); + expect(await svc.canAccessTrip(900, 900)).toMatchObject({ id: 900, user_id: 900 }); + expect(await svc.canAccessTrip(900, 901)).toMatchObject({ id: 900 }); + expect(await svc.canAccessTrip(900, 999)).toBeUndefined(); - expect(svc.isOwner(900, 900)).toBe(true); - expect(svc.isOwner(900, 901)).toBe(false); + expect(await svc.isOwner(900, 900)).toBe(true); + expect(await svc.isOwner(900, 901)).toBe(false); - expect(svc.getPlaceWithTags(999999)).toBeNull(); + expect(await svc.getPlaceWithTags(999999)).toBeNull(); svc.run("INSERT INTO places (id, trip_id, name) VALUES (900, 900, 'DbSvc Place')"); - expect(svc.getPlaceWithTags(900)).toMatchObject({ + expect(await svc.getPlaceWithTags(900)).toMatchObject({ id: 900, name: 'DbSvc Place', category: null,