mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(server): trip-access primitives async at every caller
This commit is contained in:
@@ -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<PlaceWithTags | null> {
|
||||
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<TripAccess | undefined> {
|
||||
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<boolean> {
|
||||
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null> {
|
||||
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.`;
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -188,7 +188,7 @@ export class BudgetService {
|
||||
private async rosterMemberIds(tripId: string | number, userIds: number[]): Promise<Set<number>> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export class CollabService {
|
||||
private readonly inFlight = new Map<string, Promise<LinkPreviewResult>>();
|
||||
|
||||
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<boolean> {
|
||||
|
||||
@@ -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<CollectionSaveResult> {
|
||||
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<Record<string, unknown>>('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<CollectionImportablesResponse> {
|
||||
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))) {
|
||||
|
||||
@@ -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<TripAccess | undefined> {
|
||||
return await canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
isOwner(tripId: number | string, userId: number): boolean {
|
||||
return isOwner(tripId, userId);
|
||||
async isOwner(tripId: number | string, userId: number): Promise<boolean> {
|
||||
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<number> {
|
||||
async rosterUserIds(tripId: number | string): Promise<Set<number>> {
|
||||
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<PlaceWithTags | null> {
|
||||
return await getPlaceWithTags(placeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class DayNotesService {
|
||||
) {}
|
||||
|
||||
async verifyTripAccess(tripId: string | number, userId: number): Promise<TripAccess | undefined> {
|
||||
return this.dbs.canAccessTrip(tripId, userId);
|
||||
return await this.dbs.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
async canEdit(trip: TripAccess, user: User): Promise<boolean> {
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,7 +61,7 @@ export class DawarichTracksService {
|
||||
to?: string,
|
||||
offsetMinutes = 0,
|
||||
): Promise<DawarichTrack | null> {
|
||||
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 = ?',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ServiceResult<string>> {
|
||||
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<ServiceResult<{ albumId: string; passphrase?: string }>> {
|
||||
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 {
|
||||
|
||||
@@ -63,7 +63,7 @@ export class UnifiedMemoriesService {
|
||||
|
||||
|
||||
async listTripPhotos(tripId: string, userId: number): Promise<ServiceResult<any[]>> {
|
||||
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<ServiceResult<any[]>> {
|
||||
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<ServiceResult<{ added: number; shared: boolean }>> {
|
||||
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<ServiceResult<true>> {
|
||||
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<ServiceResult<true>> {
|
||||
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<ServiceResult<true>> {
|
||||
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<ServiceResult<true>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Set<number>> {
|
||||
return this.db.rosterUserIds(tripId);
|
||||
return await this.db.rosterUserIds(tripId);
|
||||
}
|
||||
|
||||
async setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class TripAccessGuard implements CanActivate {
|
||||
if (!user) throw new HttpException({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const tripId = Number((request.params as Record<string, string>)?.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);
|
||||
|
||||
@@ -70,7 +70,7 @@ export class TripOwnerGuard implements CanActivate {
|
||||
let trip = request[TRIP_REQUEST_KEY];
|
||||
if (!trip) {
|
||||
const tripId = Number((request.params as Record<string, string>)?.[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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -160,7 +160,7 @@ export class PlacesService {
|
||||
private async tagsOnTrip(tripId: string | number, tagIds: number[]): Promise<number[]> {
|
||||
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.
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -55,7 +55,7 @@ export class TripWarningsMcp {
|
||||
async getTripWarnings({ tripId }: { tripId: number }, ctx: McpContext): Promise<McpTextResult> {
|
||||
// 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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -62,7 +62,7 @@ export class PublicApiService {
|
||||
* trip ids exist.
|
||||
*/
|
||||
async getTrip(tripId: number, userId: number, include: PublicApiInclude[], granted: readonly string[] = include): Promise<PublicApiTrip | null> {
|
||||
if (!this.db.canAccessTrip(tripId, userId)) return null;
|
||||
if (!(await this.db.canAccessTrip(tripId, userId))) return null;
|
||||
const row = this.db.get<TripRow>(
|
||||
`SELECT id, title, description, start_date, end_date, currency, is_archived, updated_at
|
||||
FROM trips WHERE id = ?`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StoredDay>(
|
||||
'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number',
|
||||
tripId,
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user