Move the trip surface onto the decorators

Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332
lines to 830 and the deps factory from 944 to 500.

trips.update keeps its two field-level gates: archiving and changing the cover each
need their own admin-configurable permission on top of trip_edit, so a plugin
cannot do either on a trip it may otherwise edit. Member management keeps its own
grant for the same kind of reason, since adding a member grants trip access, and
the owner still cannot be removed through it because that would orphan the trip.

canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's
edit permission through it and has not moved yet.
This commit is contained in:
Maurice
2026-08-09 13:24:39 +02:00
committed by Maurice
parent c326d39a07
commit 2ec6d006b0
11 changed files with 455 additions and 422 deletions
@@ -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.
@@ -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<TripsService['create']>[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<TripsService['updateTrip']>[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) => {
+4 -101
View File
@@ -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<string, unknown>): unknown;
// --- Trip creation (the 'trip_create' permission; owner = acting user) ---
canCreateTrip(userId: number): boolean;
createTripForUser(userId: number, input: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>, TRIP_STR_LIMITS);
this.requireTripEdit(tripId, actor, deps.canEditTrip);
return deps.updateTrip(tripId, actor, parsed.data as Record<string, unknown>);
});
}
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<string, unknown>, TRIP_STR_LIMITS);
if (!deps.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips');
return deps.createTripForUser(actor, parsed.data as Record<string, unknown>);
});
}
// rates.get now lives in src/nest/budget/exchange-rates.rpc.ts.
+6 -2
View File
@@ -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],
})
+186
View File
@@ -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<string, number> = { 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, TRIP_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, TRIP_EDIT_ACTION);
const input = parsed.data as Record<string, unknown>;
// 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<TripsService['updateTrip']>[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<string, unknown>, 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<string, unknown>, 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<TripsService['create']>[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<string, unknown>, 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<string, unknown>, 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 };
}
}