diff --git a/server/src/nest/plugins/host/plugin-guards.service.ts b/server/src/nest/plugins/host/plugin-guards.service.ts index 601105463..fe6cd016d 100644 --- a/server/src/nest/plugins/host/plugin-guards.service.ts +++ b/server/src/nest/plugins/host/plugin-guards.service.ts @@ -74,6 +74,16 @@ export class PluginGuards { return this.permissions.checkPermission(action, user.role ?? 'user', trip.user_id, userId, trip.user_id !== userId); } + /** + * A permission that is NOT bound to a trip, so it cannot go through + * requireTripEdit. `trip_create` is the only one today: it has no trip to check + * against yet, which is why the owner id is passed as null. + */ + canCreateAs(action: string, userId: number): boolean { + const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; + return this.permissions.checkPermission(action, user?.role ?? 'user', null, userId, false); + } + /** * A subsystem read is refused when its addon is off, matching the app, where a * disabled addon means there is simply nothing to read. diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 9ee6e6272..1596b13ee 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -237,20 +237,6 @@ export class PluginHostDepsFactory { }, // --- Member add (member_manage). Grants trip access — the target must exist; // the acting user is recorded as the inviter. Owner/duplicate adds are no-ops. --- - canManageMembers: (tripId, userId) => this.canEditTripAs('member_manage', tripId, userId), - addTripMember: (tripId, targetUserId, invitedBy) => { - const target = this.db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId) as { id: number } | undefined; - if (!target) throw new ForbiddenResource(`no user ${targetUserId}`); - return this.membership.joinTripAsMember(tripId, targetUserId, invitedBy); - }, - removeTripMember: (tripId, targetUserId) => { - // Never remove the OWNER via this path — that would orphan the trip. Ownership - // transfer is a separate, deliberate action, not a member-management side effect. - const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; - if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner'); - this.trips.removeMember(tripId, targetUserId); - return { removed: true }; - }, // --- Host-mediated notifications. Recipient resolution + channel fan-out + // per-user preferences are all owned by NotificationsService.send; the plugin // supplies only the target (host-scoped by the router) + plain text. actorId is @@ -366,6 +352,8 @@ export class PluginHostDepsFactory { // linked to the trip carries a skeleton entry per day-assigned place, and // without the hooks it keeps the old title/location — or a dead entry — until // somebody reloads it. --- + // Consumed by the db:meta gate; the trip writes themselves have moved. + canEditTrip: (tripId, userId) => this.canEditTripAs('trip_edit', tripId, userId), canEditPlaces: (tripId, userId) => this.canEditTripAs('place_edit', tripId, userId), // --- Days (day_edit). getDay scopes the row to the trip before any write. --- canEditDays: (tripId, userId) => this.canEditTripAs('day_edit', tripId, userId), @@ -374,59 +362,14 @@ export class PluginHostDepsFactory { // self-check this — the controllers do, so we reproduce it here). --- // --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip // is only visible to its owner, who refetches (same as the REST POST). --- - canCreateTrip: (userId) => { - const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; - return this.permissions.checkPermission('trip_create', u?.role ?? 'user', null, userId, false); - }, - createTripForUser: (userId, input) => { - try { - const result = this.trips.create(userId, input as unknown as Parameters[1]); - return result.trip; - } catch (e) { - if (e instanceof ValidationError) throw new BadParams(e.message); - throw e; - } - }, // --- Exchange rates: the same cached upstream feed the budget uses (tenant-free). --- // --- Trip (trip_edit). Only the schema-writable fields reach updateTrip; its // NotFound/Validation errors are mapped to clean RPC codes. --- - canEditTrip: (tripId, userId) => this.canEditTripAs('trip_edit', tripId, userId), - updateTrip: (tripId, userId, input) => { - // The REST controller gates two fields behind their OWN admin-configurable - // permissions, separate from trip_edit — reproduce that here so a plugin (or - // its member user) can't archive or re-cover a trip it may only edit. - if ('is_archived' in input && !this.canEditTripAs('trip_archive', tripId, userId)) { - throw new ForbiddenResource(`no permission to archive trip ${tripId}`); - } - if ('cover_image' in input && !this.canEditTripAs('trip_cover_upload', tripId, userId)) { - throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`); - } - const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; - try { - // The no-rebase updateTrip core — parity with the legacy host path, - // which never re-anchored the budget currency. - const result = this.trips.updateTrip(tripId, userId, input as Parameters[2], u?.role ?? 'user'); - this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); - return result.updatedTrip; - } catch (e) { - if (e instanceof ValidationError) throw new BadParams(e.message); - if (e instanceof NotFoundError) throw new ForbiddenResource(e.message); - throw e; - } - }, // --- Cross-trip reads. The membership predicate is baked into listTrips, so a // plugin only ever sees the acting user's own trips/reservations (no raw // cross-tenant SELECT). Reuses the same hydrated services as the REST paths. --- - listTripsForUser: (userId) => this.trips.list(userId, null), - listReservationsForUser: (userId) => { - const trips = this.trips.list(userId, null) as Array<{ id: number }>; - return trips.flatMap((t) => this.reservations.list(String(t.id))); - }, // --- Trip-scoped hydrated reads (membership already checked by tripRead). Same // services as the REST GETs, so plugins see the exact planner shapes. --- - listTripDays: (tripId) => (this.days.list(tripId) as { days: unknown[] }).days, - listTripReservations: (tripId) => this.reservations.list(String(tripId)), - listTripAccommodations: (tripId) => this.days.listAccommodations(tripId) as unknown[], // --- Accommodations (lodging blocks, day_edit). Delegates to DaysService so the // partner hotel reservation, the metadata sync and the delete cascade behave // exactly like the accommodations REST controller, cascade broadcasts included. --- @@ -596,14 +539,6 @@ export class PluginHostDepsFactory { this.notifyBooking(actingUserId, tripId, deleted.title, deleted.type || ''); return { deleted: true }; }, - // --- Packing (packing_edit). Reuses PackingService + replicates the #858 - // privacy-scoped broadcasts (create/delete via emitPackingToViewers, update via - // the four-case broadcastPackingUpdate) so a private item never leaks room-wide. --- - // --- Packing bags (no privacy — plain room broadcasts). --- - // --- Read-convenience: weather (host cache, tenant-free), categories (global), roster --- - tripMembers: (tripId) => - this.db.prepare('SELECT u.id, u.username, u.display_name, u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE tm.trip_id = ?').all(tripId) as unknown[], - // --- Todos (core, trip-scoped; the app's 'packing_edit' permission). --- // --- Plugin metadata (db:meta). A per-plugin namespaced key/value store keyed // to a core entity; the plugin only ever sees rows tagged with its own id. --- metaEntityTrip: (entityType, entityId) => { diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts index 71db0de80..5303f121e 100644 --- a/server/src/nest/plugins/host/rpc-host.ts +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -109,9 +109,6 @@ export interface HostDeps { voteCollabPoll(tripId: number, pollId: number, optionIndex: number, actingUserId: number): unknown; createCollabMessage(tripId: number, text: string, replyTo: number | undefined, actingUserId: number): unknown; // --- Member add (the DISTINCT member_manage permission — never bundled) --- - canManageMembers(tripId: number, userId: number): boolean; - addTripMember(tripId: number, targetUserId: number, invitedBy: number): unknown; - removeTripMember(tripId: number, targetUserId: number, actingUserId: number): unknown; /** The acting user's own journals (journey addon must be enabled). */ listJournalsForUser(userId: number): unknown; /** The entries of one of the acting user's journeys (journey addon; access-checked). */ @@ -165,29 +162,22 @@ export interface HostDeps { /** Delete a budget item from a trip (and broadcast); returns { deleted: true }. */ deleteCost(tripId: number, itemId: number): unknown; // --- Places (the 'place_edit' permission) --- + /** Still needed by the db:meta gate, which has not moved yet. */ + canEditTrip(tripId: number, userId: number): boolean; canEditPlaces(tripId: number, userId: number): boolean; // --- Days + itinerary (the 'day_edit' permission) --- canEditDays(tripId: number, userId: number): boolean; /** Assign a place to a day (both trip-scoped by the wiring); returns the assignment. */ /** Remove a day-assignment (trip-scoped by the wiring). */ // --- Trip (the 'trip_edit' permission) --- - canEditTrip(tripId: number, userId: number): boolean; - updateTrip(tripId: number, userId: number, input: Record): unknown; // --- Trip creation (the 'trip_create' permission; owner = acting user) --- - canCreateTrip(userId: number): boolean; - createTripForUser(userId: number, input: Record): unknown; // --- Exchange rates (tenant-free, like weather) --- // --- Cross-trip reads (membership baked in — every trip the acting user can access) --- /** Every trip the acting user owns or is a member of (the listTrips baseline). */ - listTripsForUser(userId: number): unknown[]; /** Every reservation across the acting user's accessible trips. */ - listReservationsForUser(userId: number): unknown[]; /** A trip's days with their assignments + day notes — the planner GET's shape. */ - listTripDays(tripId: number): unknown[]; /** A trip's reservations, hydrated like the REST list (endpoints, day_positions, joins). */ - listTripReservations(tripId: number): unknown[]; /** A trip's lodging blocks (day_accommodations) with the joined place fields. */ - listTripAccommodations(tripId: number): unknown[]; // --- Accommodations write (the 'day_edit' permission, like the accommodations REST path) --- /** Create a lodging block (auto-creates its partner hotel reservation + broadcasts); returns it. */ createAccommodation(tripId: number, input: Record): unknown; @@ -209,7 +199,6 @@ export interface HostDeps { /** Delete a packing item; owner+recipients-scoped packing:deleted broadcast; returns { deleted: true }. */ // --- Packing bags (packing_edit; no privacy — broadcast to the whole room) --- // --- Read-convenience: weather (tenant-free), categories (global), the trip roster --- - tripMembers(tripId: number): unknown[]; // --- Tags (the acting user's own; no trip) --- // --- Todos (core, trip-scoped; the 'packing_edit' permission, like the REST path) --- // --- Plugin metadata on core entities (db:meta) --- @@ -255,49 +244,8 @@ export class PluginRpcHost { this.methods.set('db.tx', (p) => deps.data.tx(asTxOps(p.ops))); } - if (has('db:read:trips')) { - this.methods.set('trips.getById', (p, uid) => - this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))), - ); - // The trip's place POOL — places carry no itinerary position of their own - // (day_id/order_index live on day_assignments), so order by created_at like - // the REST list does. Use trips.getDays for the day-ordered itinerary. - this.methods.set('trips.getPlaces', (p, uid) => - this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC').all(num(p.tripId, 'tripId'))), - ); - // Hydrated like the REST list (endpoints, day_positions, joins, normalized - // accommodation_id) — a strict superset of the raw row, so older callers - // only ever gain fields. - this.methods.set('trips.getReservations', (p, uid) => - this.tripRead(p, uid, () => deps.listTripReservations(num(p.tripId, 'tripId'))), - ); - // Days with their assignments + day notes: the read half of db:write:days — - // without it a writer can't even discover the day ids it may edit. - this.methods.set('trips.getDays', (p, uid) => - this.tripRead(p, uid, () => deps.listTripDays(num(p.tripId, 'tripId'))), - ); - // Lodging blocks (day_accommodations) with their joined place fields. Reads - // ride on db:read:trips like every other trip entity: the REST GET, too, - // asks only for trip access. - this.methods.set('trips.getAccommodations', (p, uid) => - this.tripRead(p, uid, () => deps.listTripAccommodations(num(p.tripId, 'tripId'))), - ); - // Cross-trip enumeration: every trip the acting user can access. Membership is - // baked into listTripsForUser, so there is no tripId to check — but a job/onLoad - // (no bound user) is refused, exactly like costs.listMine. - this.methods.set('trips.listMine', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('trip reads require an authenticated user context'); - return deps.listTripsForUser(uid); - }); - // Cross-trip reservations feed (dashboards): reservations across every accessible - // trip. Same membership predicate + no-user refusal. - this.methods.set('reservations.listMine', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('reservation reads require an authenticated user context'); - return deps.listReservationsForUser(uid); - }); - // The trip's member roster (ids + display fields only), membership-checked. - this.methods.set('trips.members', (p, uid) => this.tripRead(p, uid, () => deps.tripMembers(num(p.tripId, 'tripId')))); - } + // trips.*, reservations.listMine and the member roster now live in + // src/nest/trips/trips.rpc.ts. // files.* now lives in src/nest/files/files.rpc.ts. if (has('db:write:collab')) { // Collab content (notes/polls/messages) under the app's collab_edit right. @@ -332,27 +280,6 @@ export class PluginRpcHost { return deps.createCollabMessage(tripId, p.text, typeof p.replyTo === 'number' ? p.replyTo : undefined, actor); }); } - if (has('db:write:members')) { - // Adding a member GRANTS TRIP ACCESS — deliberately its own permission behind - // the app's member_manage right (default: trip owner only), never bundled with - // a lower-risk write. The acting user is recorded as the inviter. - this.methods.set('trips.addMember', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const targetUserId = num(p.userId, 'userId'); - const actor = this.requireActor(uid, 'trip member'); - this.requireTripEdit(tripId, actor, deps.canManageMembers); - return deps.addTripMember(tripId, targetUserId, actor); - }); - // Symmetry with addMember: a directory-sync integration must be able to reconcile - // DEPARTURES, not only additions. Same grant + the same manage-members gate. - this.methods.set('trips.removeMember', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const targetUserId = num(p.userId, 'userId'); - const actor = this.requireActor(uid, 'trip member'); - this.requireTripEdit(tripId, actor, deps.canManageMembers); - return deps.removeTripMember(tripId, targetUserId, actor); - }); - } // User-scoped addon reads: the acting user's OWN journals/atlas/vacay across all // their trips (not one trip), so — like costs.listMine — they are gated on a bound @@ -571,31 +498,7 @@ export class PluginRpcHost { // itinerary.* now lives in src/nest/assignments/itinerary.rpc.ts. - if (has('db:write:trips')) { - this.methods.set('trips.update', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const actor = this.requireActor(uid, 'trip'); - const parsed = tripUpdateRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - this.capStrings(parsed.data as Record, TRIP_STR_LIMITS); - this.requireTripEdit(tripId, actor, deps.canEditTrip); - return deps.updateTrip(tripId, actor, parsed.data as Record); - }); - } - if (has('db:create:trips')) { - // Create a brand-new trip owned by the acting user — the capability that - // unlocks importers (Google MyMaps, booking dumps, calendar sync). Gated by - // the app's own 'trip_create' right + a bound user (a job can't create one). - this.methods.set('trips.create', (p, uid) => { - const actor = this.requireActor(uid, 'trip'); - const parsed = tripCreateRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - this.capStrings(parsed.data as Record, TRIP_STR_LIMITS); - if (!deps.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips'); - return deps.createTripForUser(actor, parsed.data as Record); - }); - } // rates.get now lives in src/nest/budget/exchange-rates.rpc.ts. diff --git a/server/src/nest/trips/trips.module.ts b/server/src/nest/trips/trips.module.ts index ba2388118..fedfe891b 100644 --- a/server/src/nest/trips/trips.module.ts +++ b/server/src/nest/trips/trips.module.ts @@ -12,6 +12,10 @@ import { AuditModule } from '../audit/audit.module'; import { PlacesModule } from '../places/places.module'; import { TripsController } from './trips.controller'; import { TripsService } from './trips.service'; +import { TripsRpc } from './trips.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; +import { RealtimeModule } from '../realtime/realtime.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { AppConfigModule } from '../app-config/app-config.module'; import { UnsplashModule } from '../unsplash/unsplash.module'; import { TripsMcp } from './trips.mcp'; @@ -20,9 +24,9 @@ import { AuthModule } from '../auth/auth.module'; /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does * not capture the nested sub-domain mounts (collab, files, ...). */ @Module({ - imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule], + imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, TripMembershipModule], controllers: [TripsController], - providers: [TripsService, TripsMcp], + providers: [TripsService, TripsMcp, TripsRpc], // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). exports: [TripsService], }) diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts new file mode 100644 index 000000000..b1553a7f2 --- /dev/null +++ b/server/src/nest/trips/trips.rpc.ts @@ -0,0 +1,186 @@ +import { tripCreateRequestSchema, tripUpdateRequestSchema } from '@trek/shared'; +import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators'; +import { PluginGuards } from '../plugins/host/plugin-guards.service'; +import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors'; +import { num } from '../plugins/host/rpc-params'; +import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; +import { RealtimeService } from '../realtime/realtime.service'; +import { DatabaseService } from '../database/database.service'; +import { ReservationsService } from '../reservations/reservations.service'; +import { DaysService } from '../days/days.service'; +import { TripMembershipService } from '../trip-membership/trip-membership.service'; +import { TripsService, NotFoundError, ValidationError } from './trips.service'; + +const TRIP_EDIT_ACTION = 'trip_edit'; +const MEMBER_MANAGE_ACTION = 'member_manage'; + +/** The REST controller caps these two, while the shared schema leaves them open. */ +const TRIP_STR_LIMITS: Record = { title: 200, description: 2000 }; + +/** + * The trip surface a plugin may reach (#plugins), including the cross-trip feeds and + * the member roster. + * + * Two things are specific to trips. updateTrip gates two individual FIELDS behind + * their own admin-configurable permissions, on top of trip_edit, so a plugin cannot + * archive or re-cover a trip it may otherwise edit. And adding a member GRANTS TRIP + * ACCESS, which is why it sits behind its own permission and the app's member_manage + * right rather than being bundled with a lower-risk write. + */ +@PluginController() +export class TripsRpc { + constructor( + private readonly trips: TripsService, + private readonly reservations: ReservationsService, + private readonly days: DaysService, + private readonly membership: TripMembershipService, + private readonly db: DatabaseService, + private readonly realtime: RealtimeService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('trips.getById', { permission: 'db:read:trips' }) + getById(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => + this.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(params.tripId, 'tripId')), + ); + } + + @PluginMethod('trips.getPlaces', { permission: 'db:read:trips' }) + getPlaces(params: Record, ctx: PluginRpcContext): unknown { + // The trip's place POOL. Places carry no itinerary position of their own + // (day_id/order_index live on day_assignments), so order by created_at like the + // REST list does. trips.getDays is the day-ordered itinerary. + return this.guards.tripRead(params, ctx, () => + this.db + .prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC') + .all(num(params.tripId, 'tripId')), + ); + } + + @PluginMethod('trips.getReservations', { permission: 'db:read:trips' }) + getReservations(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => this.reservations.list(String(num(params.tripId, 'tripId')))); + } + + @PluginMethod('trips.getDays', { permission: 'db:read:trips' }) + getDays(params: Record, ctx: PluginRpcContext): unknown { + // Days with their assignments and notes: the read half of db:write:days, without + // which a writer cannot even discover the day ids it may edit. + return this.guards.tripRead( + params, + ctx, + () => (this.days.list(num(params.tripId, 'tripId')) as { days: unknown[] }).days, + ); + } + + @PluginMethod('trips.getAccommodations', { permission: 'db:read:trips' }) + getAccommodations(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => this.days.listAccommodations(num(params.tripId, 'tripId')) as unknown[]); + } + + @PluginMethod('trips.listMine', { permission: 'db:read:trips' }) + listMine(_params: Record, ctx: PluginRpcContext): unknown { + // Membership is baked into the service, so there is no tripId to check, but a + // job or onLoad with no bound user is refused exactly like costs.listMine. + if (ctx.actingUserId === undefined) throw new ForbiddenResource('trip reads require an authenticated user context'); + return this.trips.list(ctx.actingUserId, null); + } + + @PluginMethod('reservations.listMine', { permission: 'db:read:trips' }) + listMyReservations(_params: Record, ctx: PluginRpcContext): unknown { + if (ctx.actingUserId === undefined) { + throw new ForbiddenResource('reservation reads require an authenticated user context'); + } + const trips = this.trips.list(ctx.actingUserId, null) as Array<{ id: number }>; + return trips.flatMap((t) => this.reservations.list(String(t.id))); + } + + @PluginMethod('trips.members', { permission: 'db:read:trips' }) + members(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => + this.db + .prepare('SELECT u.id, u.username, u.display_name, u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE tm.trip_id = ?') + .all(num(params.tripId, 'tripId')) as unknown[], + ); + } + + @PluginMethod('trips.update', { permission: 'db:write:trips' }) + update(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const actor = this.guards.requireActor(ctx, 'trip'); + const parsed = tripUpdateRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + this.guards.capStrings(parsed.data as Record, TRIP_STR_LIMITS); + this.guards.requireTripEdit(tripId, actor, TRIP_EDIT_ACTION); + const input = parsed.data as Record; + // Two fields carry their own permissions on top of trip_edit, exactly as the REST + // controller gates them. + if ('is_archived' in input && !this.guards.canEditAs('trip_archive', tripId, actor)) { + throw new ForbiddenResource(`no permission to archive trip ${tripId}`); + } + if ('cover_image' in input && !this.guards.canEditAs('trip_cover_upload', tripId, actor)) { + throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`); + } + const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(actor) as { role?: string } | undefined; + try { + // The no-rebase core, parity with the legacy host path, which never + // re-anchored the budget currency. + const result = this.trips.updateTrip(tripId, actor, input as Parameters[2], user?.role ?? 'user'); + this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip }); + return result.updatedTrip; + } catch (e) { + if (e instanceof ValidationError) throw new BadParams(e.message); + if (e instanceof NotFoundError) throw new ForbiddenResource(e.message); + throw e; + } + } + + @PluginMethod('trips.create', { permission: 'db:create:trips' }) + create(params: Record, ctx: PluginRpcContext): unknown { + // The capability that unlocks importers (MyMaps, booking dumps, calendar sync). + // No broadcast: a new trip is only visible to its owner, who refetches. + const actor = this.guards.requireActor(ctx, 'trip'); + const parsed = tripCreateRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + this.guards.capStrings(parsed.data as Record, TRIP_STR_LIMITS); + if (!this.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips'); + try { + return this.trips.create(actor, parsed.data as unknown as Parameters[1]).trip; + } catch (e) { + if (e instanceof ValidationError) throw new BadParams(e.message); + throw e; + } + } + + /** trip_create is not trip-scoped, so it cannot go through requireTripEdit. */ + private canCreateTrip(userId: number): boolean { + return this.guards.canCreateAs('trip_create', userId); + } + + @PluginMethod('trips.addMember', { permission: 'db:write:members' }) + addMember(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const targetUserId = num(params.userId, 'userId'); + const actor = this.guards.requireActor(ctx, 'trip member'); + this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); + const target = this.db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId) as { id: number } | undefined; + if (!target) throw new ForbiddenResource(`no user ${targetUserId}`); + // The acting user is recorded as the inviter. + return this.membership.joinTripAsMember(tripId, targetUserId, actor); + } + + @PluginMethod('trips.removeMember', { permission: 'db:write:members' }) + removeMember(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const targetUserId = num(params.userId, 'userId'); + const actor = this.guards.requireActor(ctx, 'trip member'); + this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION); + // Never remove the OWNER through this path: that would orphan the trip. + // Ownership transfer is a separate, deliberate action. + const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; + if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner'); + this.trips.removeMember(tripId, targetUserId); + return { removed: true }; + } +} diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index 5b6e7d5f9..33aab7fad 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -11,6 +11,7 @@ import { FilesRpc } from '../../src/nest/files/files.rpc'; import { PlacesRpc } from '../../src/nest/places/places.rpc'; import { DaysRpc } from '../../src/nest/days/days.rpc'; import { ItineraryRpc } from '../../src/nest/assignments/itinerary.rpc'; +import { TripsRpc } from '../../src/nest/trips/trips.rpc'; /** * The stubbed HostDeps every plugin router test builds on. It used to live inside @@ -196,5 +197,6 @@ export function allRpcControllers(): object[] { new PlacesRpc(anyService(), anyService(), anyService(), anyService()), new DaysRpc(anyService(), anyService(), anyService()), new ItineraryRpc(anyService(), anyService(), anyService()), + new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), ]; } diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 6973f5bd1..32c6128b9 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -451,19 +451,8 @@ describe('host-deps factory — planner write + metadata deps', () => { // #1705: a plugin itinerary write has to reach open sessions exactly like the REST // route. The delete payload needs the dayId the client reducer evicts by, and both // directions run the same journey-skeleton reconcile the controller/MCP tool run. - it('trips.update: archive/cover need their own permission; service errors map to RPC codes', async () => { - const h = host('db:write:trips'); - expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'T' } })).ok).toBe(true); - checkPermission.mockImplementation((action: string) => action !== 'trip_archive'); - expect((await call(h, 'trips.update', { tripId: 1, input: { is_archived: 1 } })).error.code).toBe('RESOURCE_FORBIDDEN'); - checkPermission.mockImplementation((action: string) => action !== 'trip_cover_upload'); - expect((await call(h, 'trips.update', { tripId: 1, input: { cover_image: '/x.jpg' } })).error.code).toBe('RESOURCE_FORBIDDEN'); - checkPermission.mockReturnValue(true); - expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS'); - expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'gone' } })).error.code).toBe('RESOURCE_FORBIDDEN'); - expect((await call(h, 'trips.update', { tripId: 1, input: { title: 'crash' } })).error.code).toBe('HOST_ERROR'); // rethrow of an unknown error - }); - + // trips.*, the cross-trip feeds and member management left this factory with the + // decorator migration; the cases now run in tests/unit/trips/trips.rpc.test.ts. it('metadata: round-trips and enforces the key/value/access limits', async () => { const h = host('db:meta'); expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true); @@ -541,48 +530,6 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read // daynotes.* left this factory with the decorator migration; the cases now run in // tests/unit/days/day-notes.rpc.test.ts against DayNotesRpc. - it('cross-trip reads enumerate accessible trips and reservations', async () => { - const h = host('db:read:trips'); - expect((await call(h, 'trips.listMine', {})).ok).toBe(true); - const r = await call(h, 'reservations.listMine', {}); - expect(r.ok).toBe(true); - expect(r.result).toHaveLength(1); - }); - - it('wave-13 wiring: collab reads, journal entries, atlas bucket and trip create delegate correctly', async () => { - // collab reads delegate to collabService and require the collab addon - const collab = host('db:read:collab'); - expect((await call(collab, 'collab.listNotes', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, title: 'Note' }]); - expect((await call(collab, 'collab.listMessages', { tripId: 1, before: 9 })).result).toEqual([{ id: 3, trip_id: 1, text: 'hi', _before: 9 }]); - isAddonEnabled.mockReturnValueOnce(false); - expect((await call(collab, 'collab.listPolls', { tripId: 1 })).error.code).toBeDefined(); // addon off -> refused - // journal.getEntries: delegates + self-gate (journey 88 = no access) - const journal = host('db:read:journal'); - expect((await call(journal, 'journal.getEntries', { journeyId: 7 })).result).toEqual([{ id: 10, journey_id: 7, author_id: 5 }]); - expect((await call(journal, 'journal.getEntries', { journeyId: 88 })).error.code).toBe('RESOURCE_FORBIDDEN'); - // atlas.bucketList - const atlas = host('db:read:atlas'); - expect((await call(atlas, 'atlas.bucketList', {})).result).toEqual([{ id: 5, user_id: 5, name: 'Kyoto' }]); - // trips.create: owner = acting user, delegates to createTrip; validation error mapped - const create = host('db:create:trips'); - expect((await call(create, 'trips.create', { input: { title: 'Japan' } })).result).toMatchObject({ id: 99, user_id: 5, title: 'Japan' }); - expect((await call(create, 'trips.create', { input: { title: 'boom' } })).error.code).toBe('BAD_PARAMS'); - }); - - it('trip-scoped reads are wired to the hydrated services (days incl. assignments, reservations incl. endpoints)', async () => { - const h = host('db:read:trips'); - const days = await call(h, 'trips.getDays', { tripId: 1 }); - expect(days.ok).toBe(true); - // listDays returns { days: [...] }; the wiring unwraps to the bare array like getPlaces - expect(days.result).toEqual([{ id: 3, trip_id: 1, day_number: 1, assignments: [], notes_items: [] }]); - const res = await call(h, 'trips.getReservations', { tripId: 1 }); - expect(res.ok).toBe(true); - expect(res.result).toEqual([{ id: 1, trip_id: 1, title: 'Flight' }]); - const acc = await call(h, 'trips.getAccommodations', { tripId: 1 }); - expect(acc.ok).toBe(true); - expect(acc.result).toEqual([{ id: 11, trip_id: 1, place_name: 'Ryokan' }]); - }); - it('accommodations create validates refs, creates via DaysService and emits the cascade broadcasts', async () => { const h = host('db:write:accommodations'); const good = await call(h, 'accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00' } }); @@ -626,30 +573,9 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read // transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the // PackingService methods the REST controller uses too, so there is one copy now. -describe('host-deps factory — Wave 1 wiring (weather/categories/tags/todos/roster/bags)', () => { - const host = (...perms: string[]) => createRealRpcHost('w1', new Set(perms)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => - h.dispatch({ k: 'req', id: 'x', method, params }, uid) - beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true) }) - afterAll(() => closePluginDataDb('w1')) - - // weather.get, categories.list and rates.get left this factory with the decorator - // migration; the cases now run in tests/unit/plugins/tenant-free.rpc.test.ts. - - // tags.* left this factory with the decorator migration. The same cases now run in - // tests/unit/tags/tags.rpc.test.ts against TagsRpc. - - it('trips.members returns the roster', async () => { - const r = await call(host('db:read:trips'), 'trips.members', { tripId: 1 }, 5) - expect(r.ok).toBe(true) - expect(Array.isArray(r.result)).toBe(true) - }) - - // todos.* left this factory with the decorator migration; the cases now run in - // tests/unit/todo/todo.rpc.test.ts against TodoRpc. - -}) +// The Wave 1 wiring suite is gone: weather, categories, rates, tags, todos, the +// roster and the packing bags all moved onto the decorators, so there was nothing +// left in it. Their cases live in the domain suites now. describe('host-deps factory — Wave 2 wiring (atlas/vacay/journal/collections writes)', () => { const host = (...perms: string[]) => createRealRpcHost('w2', new Set(perms)) @@ -725,15 +651,6 @@ describe('host-deps factory — Wave 3 wiring (files write / collab / member-add expect(((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') }) - it('trips.addMember verifies the target user exists and reports owner-add as joined:false', async () => { - const h = host('db:write:members') - const r = await call(h, 'trips.addMember', { tripId: 1, userId: 6 }) - expect(r.ok).toBe(true) - expect(r.result).toMatchObject({ joined: true }) - const ownerAdd = await call(h, 'trips.addMember', { tripId: 1, userId: 5 }) // owner -> no-op - expect(ownerAdd.result).toMatchObject({ joined: false }) - expect(((await call(h, 'trips.addMember', { tripId: 1, userId: 12345 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - }) }) describe('host-deps factory — Wave 4 wiring (notify / ai)', () => { diff --git a/server/tests/unit/plugins/rpc-coverage.test.ts b/server/tests/unit/plugins/rpc-coverage.test.ts index 483df3313..2ae64f175 100644 --- a/server/tests/unit/plugins/rpc-coverage.test.ts +++ b/server/tests/unit/plugins/rpc-coverage.test.ts @@ -54,8 +54,8 @@ describe('plugin RPC coverage ledger', () => { // Update these two numbers in every rollout PR. They make the diff state the size // of the move out loud, which is the number a reviewer wants to see. const total = KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length; - expect(fromRegistry.size).toBe(38); - expect(legacy.size).toBe(total - 38); + expect(fromRegistry.size).toBe(50); + expect(legacy.size).toBe(total - 50); }); it('RPCLEDGER-006 the migrated methods are exactly the ones this PR claims', () => { @@ -89,6 +89,7 @@ describe('plugin RPC coverage ledger', () => { 'places.delete', 'places.update', 'rates.get', + 'reservations.listMine', 'tags.create', 'tags.delete', 'tags.list', @@ -97,6 +98,17 @@ describe('plugin RPC coverage ledger', () => { 'todos.delete', 'todos.list', 'todos.update', + 'trips.addMember', + 'trips.create', + 'trips.getAccommodations', + 'trips.getById', + 'trips.getDays', + 'trips.getPlaces', + 'trips.getReservations', + 'trips.listMine', + 'trips.members', + 'trips.removeMember', + 'trips.update', 'weather.get', ]); }); diff --git a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts index dd46b54cb..c9fdeabbb 100644 --- a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts +++ b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts @@ -13,11 +13,25 @@ import { makeDeps } from '../../helpers/rpc-host-deps'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PackingRpc } from '../../../src/nest/packing/packing.rpc'; import type { PackingService } from '../../../src/nest/packing/packing.service'; -import type { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; +import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; const req = (method: string, params: Record): RpcRequest => ({ k: 'req', id: 'x', method, params }); +/** trips.getById lives on the decorators now, so the audit cases bind it through them. */ +const tripsRegistry = () => { + const db = { + canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined), + prepare: () => ({ get: () => ({ id: 1, title: 'Japan' }), all: () => [] }), + } as unknown as DatabaseService; + const guards = new PluginGuards(db, {} as never, {} as never); + return createTestPluginRegistry([ + new TripsRpc({} as never, {} as never, {} as never, {} as never, db, {} as never, guards), + ]); +}; + describe('dispatch strips the supervisor _inv marker', () => { it('RPCINV-001 the handler never sees _inv', async () => { const deps = makeDeps(); @@ -37,7 +51,7 @@ describe('dispatch strips the supervisor _inv marker', () => { it('RPCINV-003 the audit row is unchanged, because auditResource reads named fields only', async () => { const deps = makeDeps(); const audit = vi.fn(); - const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit }); + const host = new PluginRpcHost('p', new Set(['db:read:trips']), { ...deps, audit }, tripsRegistry()); await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' }), 42); expect(audit).toHaveBeenCalledWith({ pluginId: 'p', @@ -50,7 +64,7 @@ describe('dispatch strips the supervisor _inv marker', () => { it('RPCINV-004 the acting user still comes from the host, not from the params', async () => { const deps = makeDeps(); - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); + const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps, tripsRegistry()); // No acting user bound: the trip read is refused even though _inv is present. const res = await host.dispatch(req('trips.getById', { tripId: 1, _inv: 'req-7' })); expect(res.ok).toBe(false); diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts index 634567034..98d80805d 100644 --- a/server/tests/unit/plugins/rpc-host.test.ts +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -42,41 +42,7 @@ describe('PluginRpcHost — capability enforcement', () => { expect((res as RpcError).error.code).toBe('UNKNOWN_METHOD'); }); - it('db:read:trips reads a trip the acting user can access', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - // The acting user is bound by the HOST (2nd dispatch arg), never from params. - const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 42); - expect(ok(res)).toBe(true); - // returns the ACTUAL trip row (title/start_date), not the access-check object - expect((res as RpcResponse).result).toMatchObject({ id: 1, title: 'Japan', start_date: '2027-01-01' }); - expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('FROM trips')); - }); - - it('db:read:trips is still RESOURCE_FORBIDDEN when the user is not a member', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 99); - expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - it('a trip read with NO bound acting user is RESOURCE_FORBIDDEN (jobs / forged calls)', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - // A plugin-supplied asUserId is ignored; without a host-bound user, deny. - const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }), undefined); - expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect(deps.canAccessTrip).not.toHaveBeenCalled(); - }); - - it('trips.getPlaces is membership-checked before the core read', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2 }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect(deps.db.prepare).not.toHaveBeenCalled(); - - const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42); - expect(ok(allowed)).toBe(true); - expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]); - }); - + // The trips, roster and member cases moved to tests/unit/trips/trips.rpc.test.ts. it('db:read:users returns only the public projection for a visible user', async () => { const host = new PluginRpcHost('p', new Set(['db:read:users']), deps); const res = await host.dispatch(req('users.getById', { id: 3 }), 42); @@ -151,50 +117,6 @@ describe('PluginRpcHost — capability enforcement', () => { expect(deps.data.close).toHaveBeenCalled(); }); - it('trips.getReservations is membership-checked and returns the hydrated list', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('trips.getReservations', { tripId: 1 }), 42); - expect(ok(res)).toBe(true); - // the REST-parity list (endpoints/day_positions), not a raw row scan - expect(deps.listTripReservations).toHaveBeenCalledWith(1); - expect(((res as RpcResponse).result as Array<{ endpoints: unknown[] }>)[0].endpoints).toEqual([]); - const forbidden = await host.dispatch(req('trips.getReservations', { tripId: 2 }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - it('trips.getDays / trips.getAccommodations ride on db:read:trips, membership-checked', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const days = await host.dispatch(req('trips.getDays', { tripId: 1 }), 42); - expect(ok(days)).toBe(true); - expect(deps.listTripDays).toHaveBeenCalledWith(1); - const acc = await host.dispatch(req('trips.getAccommodations', { tripId: 1 }), 42); - expect(ok(acc)).toBe(true); - expect(deps.listTripAccommodations).toHaveBeenCalledWith(1); - for (const m of ['trips.getDays', 'trips.getAccommodations']) { - const forbidden = await host.dispatch(req(m, { tripId: 2 }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - const noUser = await host.dispatch(req(m, { tripId: 1 }), undefined); - expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - } - }); - - it('trips.listMine returns the acting user\'s trips; a job (no user) is refused', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('trips.listMine'), 42); - expect(ok(res)).toBe(true); - expect((res as RpcResponse).result).toHaveLength(2); - const noUser = await host.dispatch(req('trips.listMine'), undefined); - expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - it('reservations.listMine aggregates across accessible trips; refused without a user', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('reservations.listMine'), 42); - expect(ok(res)).toBe(true); - expect((res as RpcResponse).result).toHaveLength(2); - expect((await host.dispatch(req('reservations.listMine'), undefined)).ok).toBe(false); - }); - it('reservations.create needs db:write:reservations + reservation_edit, membership-checked', async () => { const host = new PluginRpcHost('p', new Set(['db:write:reservations']), deps); const good = await host.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel Tokyo' } }), 42); @@ -277,28 +199,6 @@ describe('PluginRpcHost — capability enforcement', () => { } }); - it('wave-13 reads: collab / journal.getEntries / atlas.bucketList / trips.create gated correctly', async () => { - // collab reads: membership-checked, need db:read:collab - const collab = new PluginRpcHost('p', new Set(['db:read:collab']), deps); - expect(ok(await collab.dispatch(req('collab.listNotes', { tripId: 1 }), 42))).toBe(true); - expect(ok(await collab.dispatch(req('collab.listMessages', { tripId: 1, before: 5 }), 42))).toBe(true); - expect((await collab.dispatch(req('collab.listPolls', { tripId: 2 }), 42)).ok).toBe(false); // no access to trip 2 - // journal.getEntries: user-bound, needs db:read:journal; a job is refused - const journal = new PluginRpcHost('p', new Set(['db:read:journal']), deps); - expect(ok(await journal.dispatch(req('journal.getEntries', { journeyId: 7 }), 42))).toBe(true); - expect((await journal.dispatch(req('journal.getEntries', { journeyId: 7 }), undefined)).ok).toBe(false); - // atlas.bucketList: user-bound, needs db:read:atlas - const atlas = new PluginRpcHost('p', new Set(['db:read:atlas']), deps); - expect(ok(await atlas.dispatch(req('atlas.bucketList'), 42))).toBe(true); - expect((await atlas.dispatch(req('atlas.bucketList'), undefined)).ok).toBe(false); - // trips.create: needs db:create:trips + the acting user's trip_create + a bound user - const create = new PluginRpcHost('p', new Set(['db:create:trips']), deps); - expect(ok(await create.dispatch(req('trips.create', { input: { title: 'Japan' } }), 42))).toBe(true); - expect((await create.dispatch(req('trips.create', { input: { title: 'x' } }), 7) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); // canCreateTrip false for 7 - expect((await create.dispatch(req('trips.create', { input: { title: 'x' } }), undefined) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); // no user - expect((await create.dispatch(req('trips.create', { input: {} }), 42) as RpcError).error.code).toBe('BAD_PARAMS'); // title required - }); - // The daynotes cases moved to tests/unit/days/day-notes.rpc.test.ts. it('collections reads are user-scoped and need db:read:collections + a bound user', async () => { @@ -314,12 +214,6 @@ describe('PluginRpcHost — capability enforcement', () => { // The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers. - it('trips.members is trip-membership-gated', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - expect(ok(await host.dispatch(req('trips.members', { tripId: 1 }), 42))).toBe(true); - expect(((await host.dispatch(req('trips.members', { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - it('atlas writes are uid-bound (code-validated, userless refused)', async () => { const host = new PluginRpcHost('p', new Set(['db:write:atlas']), deps); expect(ok(await host.dispatch(req('atlas.markCountry', { code: 'jp' }), 42))).toBe(true); @@ -372,18 +266,6 @@ describe('PluginRpcHost — capability enforcement', () => { expect(((await host.dispatch(req('collab.createNote', { tripId: 2, input: { title: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); }); - it('trips.addMember is its own permission (member_manage), host-bound inviter', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:members']), deps); - const good = await host.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), 42); - expect(ok(good)).toBe(true); - expect(deps.addTripMember).toHaveBeenCalledWith(1, 6, 42); // inviter = HOST-bound acting user - expect(((await host.dispatch(req('trips.addMember', { tripId: 2, userId: 6 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect((await host.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), undefined)).ok).toBe(false); - // and NOT reachable via any other write grant - const otherGrant = new PluginRpcHost('p', new Set(['db:write:trips', 'db:write:collab']), deps); - expect((await otherGrant.dispatch(req('trips.addMember', { tripId: 1, userId: 6 }), 42)).ok).toBe(false); - }); - it('notify.send forces the recipient to the acting user or a member trip; admin scope refused', async () => { const host = new PluginRpcHost('p', new Set(['notify:send']), deps); expect(ok(await host.dispatch(req('notify.send', { input: { title: 'Delay', body: 'AB123 is late', scope: 'user', targetId: 42 } }), 42))).toBe(true); @@ -478,16 +360,6 @@ describe('PluginRpcHost — capability enforcement', () => { expect((res as RpcError).error.message).toBe('internal error'); }); - it('coerces numeric string params and tolerates a missing params object', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:own']), deps); - // tripId as a string -> coerced to a number; acting user is the bound host arg - const res = await host.dispatch(req('trips.getById', { tripId: '1' }), 42); - expect(ok(res)).toBe(true); - // a request with no params object at all -> BAD_PARAMS (sql missing), not a crash - const noParams = await host.dispatch({ k: 'req', id: 'y', method: 'db.query', params: undefined }); - expect((noParams as RpcError).error.code).toBe('BAD_PARAMS'); - }); - // ── Costs (budget items): db:read:costs / db:write:costs ──────────────────── it('db:read:costs reads a trip the acting user can access', async () => { @@ -669,13 +541,6 @@ describe('PluginRpcHost — capability enforcement', () => { expect((res as RpcError).error.code).toBe('PERMISSION_DENIED'); }); - it('db:write:trips updates a trip the acting user may edit', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:trips']), deps); - const res = await host.dispatch(req('trips.update', { tripId: 1, input: { title: 'Renamed' } }), 42); - expect(ok(res)).toBe(true); - expect(deps.updateTrip).toHaveBeenCalledWith(1, 42, expect.objectContaining({ title: 'Renamed' })); - }); - // --- Plugin metadata (db:meta) --- it('db:meta stores and reads namespaced metadata on an accessible entity', async () => { const host = new PluginRpcHost('p', new Set(['db:meta']), deps); @@ -726,29 +591,6 @@ describe('PluginRpcHost — capability enforcement', () => { // invalid column sails through. Drive the trip reads against a REAL schema-loaded // SQLite instead — `places` has no day_id/position (those live on day_assignments), // so the old `ORDER BY day_id, position` threw `no such column` on every call. - it('trips.getPlaces runs against the real places schema and returns the trip pool', async () => { - const realDb = new Database(':memory:'); - createTables(realDb); - realDb.exec(` - INSERT INTO users (id, username, email, password_hash) VALUES (42, 'ada', 'ada@example.com', 'x'); - INSERT INTO trips (id, user_id, title) VALUES (1, 42, 'Japan'), (2, 42, 'Peru'); - INSERT INTO places (id, trip_id, name, created_at) VALUES - (1, 1, 'Older', '2027-01-01 10:00:00'), - (2, 1, 'Newer', '2027-01-02 10:00:00'), - (3, 2, 'Other trip', '2027-01-03 10:00:00'); - `); - deps.db = realDb as unknown as HostDeps['db']; - - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42); - - expect(ok(res)).toBe(true); - const rows = (res as RpcResponse).result as Array<{ id: number; name: string }>; - // only trip 1's places, newest first (mirrors the REST list's created_at DESC) - expect(rows.map(r => r.name)).toEqual(['Newer', 'Older']); - realDb.close(); - }); - it('meta WRITES need the entity edit permission — a read-only member is RESOURCE_FORBIDDEN', async () => { // Member can access the trip but not edit it (viewer role). (deps.canEditTrip as ReturnType).mockReturnValue(false); diff --git a/server/tests/unit/trips/trips.rpc.test.ts b/server/tests/unit/trips/trips.rpc.test.ts new file mode 100644 index 000000000..8f5504c22 --- /dev/null +++ b/server/tests/unit/trips/trips.rpc.test.ts @@ -0,0 +1,208 @@ +/** + * The trip plugin surface after it moved onto @PluginMethod: the trip-scoped reads, + * the two cross-trip feeds, the roster, the update, the create and the two member + * operations. + * + * Two things get their own cases. trips.update gates two individual FIELDS behind + * permissions of their own on top of trip_edit, and adding a member GRANTS TRIP + * ACCESS, which is why it sits behind its own permission rather than any other write. + */ +import { describe, it, expect, vi } from 'vitest'; +import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; +import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; +import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; +import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; +import { TripsModule } from '../../../src/nest/trips/trips.module'; +import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; +import type { TripsService } from '../../../src/nest/trips/trips.service'; +import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; +import type { DaysService } from '../../../src/nest/days/days.service'; +import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; +import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope'; +import { makeDeps } from '../../helpers/rpc-host-deps'; + +const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); + +const ALL_TRIP_GRANTS = ['db:read:trips', 'db:write:trips', 'db:create:trips', 'db:write:members']; + +/** Trip 1 belongs to user 42. `allow` decides each individual permission check. */ +export function build(opts: { allow?: (action: string) => boolean; updateThrows?: Error } = {}) { + const trips = { + list: vi.fn(() => [{ id: 1, title: 'Japan' }]), + updateTrip: vi.fn(() => { + if (opts.updateThrows) throw opts.updateThrows; + return { updatedTrip: { id: 1, title: 'Japan' } }; + }), + create: vi.fn((userId: number) => ({ trip: { id: 99, user_id: userId } })), + removeMember: vi.fn(), + } as unknown as TripsService & Record>; + const reservations = { list: vi.fn(() => [{ id: 5 }]) } as unknown as ReservationsService & Record>; + const days = { + list: vi.fn(() => ({ days: [{ id: 3 }] })), + listAccommodations: vi.fn(() => [{ id: 11 }]), + } as unknown as DaysService & Record>; + const membership = { joinTripAsMember: vi.fn(() => ({ joined: true })) } as unknown as TripMembershipService & Record>; + const db = { + canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), + prepare: vi.fn((sql: string) => ({ + get: (arg: number) => { + if (sql.includes('FROM users WHERE')) return arg === 404 ? undefined : { id: arg, role: 'user' }; + if (sql.includes('SELECT user_id FROM trips')) return { user_id: 42 }; + return { id: 1, title: 'Japan' }; + }, + all: () => [{ id: 7, name: 'Place' }], + })), + } as unknown as DatabaseService; + const permissions = { checkPermission: vi.fn((a: string) => (opts.allow ? opts.allow(a) : true)) } as unknown as PermissionsService; + const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; + const guards = new PluginGuards(db, permissions, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService); + const rpc = new TripsRpc(trips, reservations, days, membership, db, realtime, guards); + const host = (...grants: string[]) => + new PluginRpcHost('p', new Set(grants.length ? grants : ALL_TRIP_GRANTS), makeDeps(), createTestPluginRegistry([rpc])); + return { trips, reservations, days, membership, realtime, permissions, host }; +} + +describe('TripsRpc reads', () => { + it('TRIPS-RPC-001 every trip-scoped read is membership-checked', async () => { + const f = build(); + const host = f.host(); + for (const method of ['trips.getById', 'trips.getPlaces', 'trips.getReservations', 'trips.getDays', 'trips.getAccommodations', 'trips.members']) { + expect((await host.dispatch(req(method, { tripId: 1 }), 42)).ok).toBe(true); + expect(((await host.dispatch(req(method, { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); + } + }); + + it('TRIPS-RPC-002 the cross-trip feeds refuse a userless context instead of listing everything', async () => { + const f = build(); + const host = f.host(); + expect((await host.dispatch(req('trips.listMine'), 42)).ok).toBe(true); + expect((await host.dispatch(req('reservations.listMine'), 42)).ok).toBe(true); + expect(((await host.dispatch(req('trips.listMine'), undefined)) as RpcError).error.message) + .toBe('trip reads require an authenticated user context'); + expect(((await host.dispatch(req('reservations.listMine'), undefined)) as RpcError).error.message) + .toBe('reservation reads require an authenticated user context'); + expect(f.trips.list).toHaveBeenCalledWith(42, null); + }); + + it('TRIPS-RPC-003 the reservation feed spans the accessible trips', async () => { + const f = build(); + await f.host().dispatch(req('reservations.listMine'), 42); + expect(f.reservations.list).toHaveBeenCalledWith('1'); + }); + + it('TRIPS-RPC-004 getDays returns the days array, not the wrapper the service returns', async () => { + const f = build(); + const res = await f.host().dispatch(req('trips.getDays', { tripId: 1 }), 42); + expect((res as { result: unknown }).result).toEqual([{ id: 3 }]); + }); +}); + +describe('TripsRpc writes', () => { + it('TRIPS-RPC-005 update needs the grant, trip_edit and a bound user', async () => { + const f = build(); + const host = f.host(); + expect((await host.dispatch(req('trips.update', { tripId: 1, input: { title: 'New' } }), 42)).ok).toBe(true); + const noUser = (await host.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), undefined)) as RpcError; + expect(noUser.error.message).toBe('trip writes require an authenticated user context'); + const readOnly = (await f.host('db:read:trips').dispatch(req('trips.update', { tripId: 1, input: {} }), 42)) as RpcError; + expect(readOnly.error.code).toBe('PERMISSION_DENIED'); + }); + + it('TRIPS-RPC-006 archiving needs trip_archive on top of trip_edit', async () => { + const f = build({ allow: (a) => a !== 'trip_archive' }); + const res = (await f.host().dispatch(req('trips.update', { tripId: 1, input: { is_archived: true } }), 42)) as RpcError; + expect(res.error.message).toBe('no permission to archive trip 1'); + expect(f.trips.updateTrip).not.toHaveBeenCalled(); + }); + + it('TRIPS-RPC-007 changing the cover needs trip_cover_upload on top of trip_edit', async () => { + const f = build({ allow: (a) => a !== 'trip_cover_upload' }); + const res = (await f.host().dispatch(req('trips.update', { tripId: 1, input: { cover_image: '/uploads/covers/a.jpg' } }), 42)) as RpcError; + expect(res.error.message).toBe('no permission to change the cover of trip 1'); + }); + + it('TRIPS-RPC-008 a plain edit is unaffected by those two field permissions', async () => { + const f = build({ allow: (a) => a !== 'trip_archive' && a !== 'trip_cover_upload' }); + expect((await f.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'New' } }), 42)).ok).toBe(true); + }); + + it('TRIPS-RPC-009 service errors map onto the right RPC codes', async () => { + const invalid = build({ updateThrows: new ValidationError('bad dates') }); + expect(((await invalid.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)) as RpcError).error) + .toMatchObject({ code: 'BAD_PARAMS', message: 'bad dates' }); + const missing = build({ updateThrows: new NotFoundError('trip not found') }); + expect(((await missing.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)) as RpcError).error) + .toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'trip not found' }); + }); + + it('TRIPS-RPC-010 an over-long title is capped like the REST controller caps it', async () => { + const f = build(); + const res = (await f.host().dispatch(req('trips.update', { tripId: 1, input: { title: 'x'.repeat(201) } }), 42)) as RpcError; + expect(res.error.message).toBe('title must be 200 characters or fewer'); + }); + + it('TRIPS-RPC-011 create runs under trip_create and never broadcasts', async () => { + const f = build(); + expect((await f.host().dispatch(req('trips.create', { input: { title: 'Japan' } }), 42)).ok).toBe(true); + expect(f.permissions.checkPermission).toHaveBeenCalledWith('trip_create', 'user', null, 42, false); + // A new trip is only visible to its owner, who refetches. + expect(f.realtime.broadcast).not.toHaveBeenCalled(); + }); + + it('TRIPS-RPC-012 without trip_create the trip is refused', async () => { + const f = build({ allow: (a) => a !== 'trip_create' }); + const res = (await f.host().dispatch(req('trips.create', { input: { title: 'Japan' } }), 42)) as RpcError; + expect(res.error.message).toBe('no permission to create trips'); + expect(f.trips.create).not.toHaveBeenCalled(); + }); +}); + +describe('TripsRpc membership', () => { + it('TRIPS-RPC-013 adding a member needs member_manage and records the inviter', async () => { + const f = build(); + expect((await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)).ok).toBe(true); + expect(f.membership.joinTripAsMember).toHaveBeenCalledWith(1, 7, 42); + }); + + it('TRIPS-RPC-014 member management is its own grant, separate from every other write', async () => { + const f = build(); + const res = (await f.host('db:write:trips').dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)) as RpcError; + // Adding a member GRANTS TRIP ACCESS, so db:write:trips must not be enough. + expect(res.error.code).toBe('PERMISSION_DENIED'); + }); + + it('TRIPS-RPC-015 an unknown target user is refused', async () => { + const f = build(); + const res = (await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 404 }), 42)) as RpcError; + expect(res.error.message).toBe('no user 404'); + expect(f.membership.joinTripAsMember).not.toHaveBeenCalled(); + }); + + it('TRIPS-RPC-016 the owner can never be removed through this path', async () => { + const f = build(); + // The stub reports user 42 as the owner of trip 1. + const res = (await f.host().dispatch(req('trips.removeMember', { tripId: 1, userId: 42 }), 42)) as RpcError; + expect(res.error.message).toBe('cannot remove the trip owner'); + expect(f.trips.removeMember).not.toHaveBeenCalled(); + }); + + it('TRIPS-RPC-017 a non-owner member is removed', async () => { + const f = build(); + expect((await f.host().dispatch(req('trips.removeMember', { tripId: 1, userId: 7 }), 42)).ok).toBe(true); + expect(f.trips.removeMember).toHaveBeenCalledWith(1, 7); + }); + + it('TRIPS-RPC-018 without member_manage nothing changes', async () => { + const f = build({ allow: (a) => a !== 'member_manage' }); + const res = (await f.host().dispatch(req('trips.addMember', { tripId: 1, userId: 7 }), 42)) as RpcError; + expect(res.error.message).toBe('no permission to edit trip 1'); + }); + + it('TRIPS-RPC-019 the class is listed in its module providers', () => { + expect(Reflect.getMetadata('providers', TripsModule) as unknown[]).toContain(TripsRpc); + }); +});