diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index cd7719990..271a15788 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { AtlasController } from './atlas.controller'; import { AtlasService } from './atlas.service'; +import { AtlasRpc } from './atlas.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { AtlasMcp } from './atlas.mcp'; /** @@ -10,8 +12,9 @@ import { AtlasMcp } from './atlas.mcp'; * it after app.init(). */ @Module({ + imports: [PluginGuardsModule], controllers: [AtlasController], - providers: [AtlasService, AtlasMcp], + providers: [AtlasService, AtlasMcp, AtlasRpc], exports: [AtlasService], }) export class AtlasModule {} diff --git a/server/src/nest/atlas/atlas.rpc.ts b/server/src/nest/atlas/atlas.rpc.ts new file mode 100644 index 000000000..71162ccb7 --- /dev/null +++ b/server/src/nest/atlas/atlas.rpc.ts @@ -0,0 +1,128 @@ +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 { asPayload, num } from '../plugins/host/rpc-params'; +import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; +import { ADDON_IDS } from '../../addons'; +import { AtlasService } from './atlas.service'; + +/** + * The atlas surface a plugin may reach (#plugins): visited countries and regions plus + * the bucket list. + * + * Every row is the acting user's own, so there is no trip scoping and no cross-tenant + * surface at all; what each method does need is a host-bound acting user and the + * Atlas addon. The addon gate used to sit in the deps factory closure, so it is + * repeated on every method here rather than assumed. + */ +@PluginController() +export class AtlasRpc { + constructor( + private readonly atlas: AtlasService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('atlas.visited', { permission: 'db:read:atlas' }) + visited(_params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'reads'); + this.requireAtlasAddon(); + return { + countries: this.atlas.listVisitedCountries(userId), + regions: this.atlas.listManuallyVisitedRegions(userId), + }; + } + + @PluginMethod('atlas.bucketList', { permission: 'db:read:atlas' }) + bucketList(_params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'reads'); + this.requireAtlasAddon(); + return this.atlas.bucketList(userId) as unknown[]; + } + + @PluginMethod('atlas.markCountry', { permission: 'db:write:atlas' }) + markCountry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + const code = this.code(params.code, 'code'); + this.requireAtlasAddon(); + this.atlas.markCountry(userId, code); + return { visited: true }; + } + + @PluginMethod('atlas.unmarkCountry', { permission: 'db:write:atlas' }) + unmarkCountry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + const code = this.code(params.code, 'code'); + this.requireAtlasAddon(); + this.atlas.unmarkCountry(userId, code); + return { visited: false }; + } + + @PluginMethod('atlas.markRegion', { permission: 'db:write:atlas' }) + markRegion(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + // A missing name falls back to the code, so the row is never nameless. + const regionName = typeof params.regionName === 'string' && params.regionName + ? params.regionName.slice(0, 128) + : String(params.regionCode ?? ''); + const regionCode = this.code(params.regionCode, 'regionCode'); + const countryCode = this.code(params.countryCode, 'countryCode'); + this.requireAtlasAddon(); + this.atlas.markRegion(userId, regionCode, regionName, countryCode); + return { visited: true }; + } + + @PluginMethod('atlas.unmarkRegion', { permission: 'db:write:atlas' }) + unmarkRegion(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + const regionCode = this.code(params.regionCode, 'regionCode'); + this.requireAtlasAddon(); + this.atlas.unmarkRegion(userId, regionCode); + return { visited: false }; + } + + @PluginMethod('atlas.createBucketItem', { permission: 'db:write:atlas' }) + createBucketItem(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + const input = asPayload(params.input); + if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required'); + this.requireAtlasAddon(); + return this.atlas.createBucketItem(userId, input as never); + } + + @PluginMethod('atlas.deleteBucketItem', { permission: 'db:write:atlas' }) + deleteBucketItem(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireAtlasUser(ctx, 'writes'); + const itemId = num(params.itemId, 'itemId'); + this.requireAtlasAddon(); + if (!this.atlas.deleteBucketItem(userId, itemId)) { + throw new ForbiddenResource(`no bucket item ${itemId} for this user`); + } + return { deleted: true }; + } + + /** + * A bound acting user. Reads and writes word the refusal differently. + * + * Deliberately does NOT check the addon: on the write paths the router evaluated + * its params (and so the code validation) before the addon gate inside the deps + * closure, so a malformed code still reports BAD_PARAMS rather than the addon. + */ + private requireAtlasUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number { + if (ctx.actingUserId === undefined) { + throw new ForbiddenResource(`atlas ${kind} require an authenticated user context`); + } + return ctx.actingUserId; + } + + private requireAtlasAddon(): void { + this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas'); + } + + /** Country and region codes are short and stored upper-cased. */ + private code(value: unknown, name: string): string { + if (typeof value !== 'string' || value.trim() === '' || value.length > 8) { + throw new BadParams(`${name} must be a short code`); + } + return value.trim().toUpperCase(); + } +} diff --git a/server/src/nest/collab/collab.module.ts b/server/src/nest/collab/collab.module.ts index b2b99a597..3e753e414 100644 --- a/server/src/nest/collab/collab.module.ts +++ b/server/src/nest/collab/collab.module.ts @@ -1,14 +1,17 @@ import { Module } from '@nestjs/common'; import { CollabController } from './collab.controller'; import { CollabService } from './collab.service'; +import { CollabRpc } from './collab.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; +import { RealtimeModule } from '../realtime/realtime.module'; import { CollabMcp } from './collab.mcp'; import { PermissionsModule } from '../permissions/permissions.module'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [PermissionsModule, AuthModule], + imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [CollabController], - providers: [CollabService, CollabMcp], + providers: [CollabService, CollabMcp, CollabRpc], // For in-container consumers (PluginHostDepsFactory). exports: [CollabService], }) diff --git a/server/src/nest/collab/collab.rpc.ts b/server/src/nest/collab/collab.rpc.ts new file mode 100644 index 000000000..dc13a6370 --- /dev/null +++ b/server/src/nest/collab/collab.rpc.ts @@ -0,0 +1,116 @@ +import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators'; +import { PluginGuards } from '../plugins/host/plugin-guards.service'; +import { BadParams } from '../plugins/host/rpc-errors'; +import { asPayload, num } from '../plugins/host/rpc-params'; +import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; +import { RealtimeService } from '../realtime/realtime.service'; +import { ADDON_IDS } from '../../addons'; +import { CollabService } from './collab.service'; + +const COLLAB_EDIT_ACTION = 'collab_edit'; + +/** + * The collab surface a plugin may reach (#plugins): notes, polls and messages. + * + * Every method is addon-gated. That gate used to live in the deps factory's closure + * rather than in the router, which is exactly how it could have been lost in this + * move: a handler calling CollabService directly would still work, just without the + * addon check, and nothing in the type system or the router tests would notice. + * Hence requireCollabAddon on every single method, and an addon-off case per method + * in the suite. + * + * Reads need membership only. The write side carries collab_edit on top. + */ +@PluginController() +export class CollabRpc { + constructor( + private readonly collab: CollabService, + private readonly realtime: RealtimeService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('collab.listNotes', { permission: 'db:read:collab' }) + listNotes(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => { + this.requireCollabAddon(); + return this.collab.listNotes(num(params.tripId, 'tripId')) as unknown[]; + }); + } + + @PluginMethod('collab.listPolls', { permission: 'db:read:collab' }) + listPolls(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => { + this.requireCollabAddon(); + return this.collab.listPolls(num(params.tripId, 'tripId')) as unknown[]; + }); + } + + @PluginMethod('collab.listMessages', { permission: 'db:read:collab' }) + listMessages(params: Record, ctx: PluginRpcContext): unknown { + return this.guards.tripRead(params, ctx, () => { + this.requireCollabAddon(); + const before = params.before != null ? num(params.before, 'before') : undefined; + return this.collab.listMessages(num(params.tripId, 'tripId'), before) as unknown[]; + }); + } + + @PluginMethod('collab.createNote', { permission: 'db:write:collab' }) + createNote(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const actor = this.guards.requireActor(ctx, 'collab note'); + const input = asPayload(params.input); + if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required'); + this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + this.requireCollabAddon(); + const note = this.collab.createNote(String(tripId), actor, input as never); + this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined); + return note; + } + + @PluginMethod('collab.createPoll', { permission: 'db:write:collab' }) + createPoll(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const actor = this.guards.requireActor(ctx, 'collab poll'); + const input = asPayload(params.input); + if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required'); + if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options'); + this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + this.requireCollabAddon(); + const poll = this.collab.createPoll(String(tripId), actor, input as never); + this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined); + return poll; + } + + @PluginMethod('collab.votePoll', { permission: 'db:write:collab' }) + votePoll(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const actor = this.guards.requireActor(ctx, 'collab poll'); + this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + this.requireCollabAddon(); + const result = this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex')); + // The service reports its own validation failures rather than throwing. + if (result.error) throw new BadParams(result.error); + this.realtime.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); + return result.poll; + } + + @PluginMethod('collab.createMessage', { permission: 'db:write:collab' }) + createMessage(params: Record, ctx: PluginRpcContext): unknown { + const tripId = num(params.tripId, 'tripId'); + const actor = this.guards.requireActor(ctx, 'collab message'); + if (typeof params.text !== 'string' || params.text.trim() === '' || params.text.length > 4000) { + throw new BadParams('message text is required (max 4000 chars)'); + } + this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); + this.requireCollabAddon(); + const replyTo = typeof params.replyTo === 'number' ? params.replyTo : null; + const result = this.collab.createMessage(String(tripId), actor, params.text, replyTo); + if (result.error) throw new BadParams(result.error); + this.realtime.broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); + return result.message; + } + + private requireCollabAddon(): void { + this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab'); + } +} diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index 65fb52efa..5548b9996 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { CollectionsController } from './collections.controller'; import { CollectionsService } from './collections.service'; +import { CollectionsRpc } from './collections.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { AppConfigModule } from '../app-config/app-config.module'; import { CollectionsMcp } from './collections.mcp'; import { AddonsModule } from '../addons/addons.module'; @@ -11,9 +13,9 @@ import { AuthModule } from '../auth/auth.module'; * Exports CollectionsService for in-container consumers (PluginsModule's * RPC host deps factory). */ @Module({ - imports: [AddonsModule, PermissionsModule, AuthModule, AppConfigModule], + imports: [AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule], controllers: [CollectionsController], - providers: [CollectionsService, CollectionsMcp], + providers: [CollectionsService, CollectionsMcp, CollectionsRpc], exports: [CollectionsService], }) export class CollectionsModule {} diff --git a/server/src/nest/collections/collections.rpc.ts b/server/src/nest/collections/collections.rpc.ts new file mode 100644 index 000000000..1bbd8b045 --- /dev/null +++ b/server/src/nest/collections/collections.rpc.ts @@ -0,0 +1,117 @@ +import { + collectionCopyToTripRequestSchema, + collectionCreateRequestSchema, + collectionSavePlaceRequestSchema, + collectionUpdateRequestSchema, +} 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 { ADDON_IDS } from '../../addons'; +import { CollectionsService } from './collections.service'; + +/** + * The collections surface a plugin may reach (#plugins). + * + * CollectionsService decides per-collection access itself (owner, admin or editor via + * its assertAccess / assertCanEdit), and reports a refusal as a status-tagged error + * rather than a typed one. mapCollectionError turns those statuses into the RPC error + * classes, so a 403 or 404 reaches the plugin as RESOURCE_FORBIDDEN and a 400 or 409 + * as BAD_PARAMS, instead of leaking out as HOST_ERROR. + */ +@PluginController() +export class CollectionsRpc { + constructor( + private readonly collections: CollectionsService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('collections.listMine', { permission: 'db:read:collections' }) + listMine(_params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireCollectionsUser(ctx, 'reads'); + this.requireCollectionsAddon(); + return this.collections.listCollections(userId); + } + + @PluginMethod('collections.get', { permission: 'db:read:collections' }) + get(params: Record, ctx: PluginRpcContext): unknown { + // getCollection is user-scoped by the service, so a plugin only ever fetches one + // the acting user may see. + const userId = this.requireCollectionsUser(ctx, 'reads'); + const id = num(params.id, 'id'); + this.requireCollectionsAddon(); + return this.collections.getCollection(userId, id); + } + + @PluginMethod('collections.create', { permission: 'db:write:collections' }) + create(params: Record, ctx: PluginRpcContext): unknown { + const parsed = collectionCreateRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + const userId = this.requireCollectionsUser(ctx, 'writes'); + this.requireCollectionsAddon(); + return this.mapCollectionError(() => this.collections.createCollection(userId, parsed.data as never)); + } + + @PluginMethod('collections.update', { permission: 'db:write:collections' }) + update(params: Record, ctx: PluginRpcContext): unknown { + const parsed = collectionUpdateRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + const userId = this.requireCollectionsUser(ctx, 'writes'); + const id = num(params.id, 'id'); + this.requireCollectionsAddon(); + return this.mapCollectionError(() => this.collections.updateCollection(userId, id, parsed.data as never, undefined)); + } + + @PluginMethod('collections.savePlace', { permission: 'db:write:collections' }) + savePlace(params: Record, ctx: PluginRpcContext): unknown { + const parsed = collectionSavePlaceRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + const userId = this.requireCollectionsUser(ctx, 'writes'); + this.requireCollectionsAddon(); + return this.mapCollectionError(() => this.collections.savePlace(userId, parsed.data as never, undefined)); + } + + @PluginMethod('collections.copyToTrip', { permission: 'db:write:collections' }) + copyToTrip(params: Record, ctx: PluginRpcContext): unknown { + const parsed = collectionCopyToTripRequestSchema.safeParse(params.input); + if (!parsed.success) throw new BadParams(`invalid copy request: ${parsed.error.issues[0]?.message ?? 'bad input'}`); + const userId = this.requireCollectionsUser(ctx, 'writes'); + this.requireCollectionsAddon(); + return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never)); + } + + @PluginMethod('collections.deletePlace', { permission: 'db:write:collections' }) + deletePlace(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireCollectionsUser(ctx, 'writes'); + const placeId = num(params.placeId, 'placeId'); + this.requireCollectionsAddon(); + this.mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined)); + return { deleted: true }; + } + + private requireCollectionsUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number { + if (ctx.actingUserId === undefined) { + throw new ForbiddenResource(`collection ${kind} require an authenticated user context`); + } + return ctx.actingUserId; + } + + private requireCollectionsAddon(): void { + this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); + } + + /** The service's status-tagged errors, mapped onto the RPC error taxonomy. */ + private mapCollectionError(fn: () => T): T { + try { + return fn(); + } catch (e) { + const status = (e as { status?: number })?.status; + const msg = e instanceof Error ? e.message : 'collection error'; + if (status === 403 || status === 404) throw new ForbiddenResource(msg); + if (status === 400 || status === 409) throw new BadParams(msg); + throw e; + } + } +} diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts new file mode 100644 index 000000000..8a86bbba6 --- /dev/null +++ b/server/src/nest/journey/journal.rpc.ts @@ -0,0 +1,111 @@ +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 { asPayload, num } from '../plugins/host/rpc-params'; +import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; +import { ADDON_IDS } from '../../addons'; +import { JourneyDomainService } from './journey-domain.service'; + +/** + * The journal surface a plugin may reach (#plugins). + * + * Journeys are user-scoped, not trip-scoped, so there is no tripId to check. The + * access decision belongs to JourneyDomainService, which self-gates every call + * against the acting user (owner or contributor) and answers with null rather than + * throwing; each handler turns that null into RESOURCE_FORBIDDEN. + */ +@PluginController() +export class JournalRpc { + constructor( + private readonly journey: JourneyDomainService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('journal.listMine', { permission: 'db:read:journal' }) + listMine(_params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'reads'); + this.requireJourneyAddon(); + return this.journey.listJourneys(userId); + } + + @PluginMethod('journal.getEntries', { permission: 'db:read:journal' }) + getEntries(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'reads'); + const journeyId = num(params.journeyId, 'journeyId'); + this.requireJourneyAddon(); + // listEntries self-gates via canAccessJourney and returns null when the user + // cannot see it. + const entries = this.journey.listEntries(journeyId, userId); + if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`); + return entries; + } + + @PluginMethod('journal.createEntry', { permission: 'db:write:journal' }) + createEntry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'writes'); + const input = asPayload(params.input); + if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required'); + const journeyId = num(params.journeyId, 'journeyId'); + this.requireJourneyAddon(); + const entry = this.journey.createEntry(journeyId, userId, input as never); + if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`); + return entry; + } + + @PluginMethod('journal.updateEntry', { permission: 'db:write:journal' }) + updateEntry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'writes'); + const entryId = num(params.entryId, 'entryId'); + this.requireJourneyAddon(); + const entry = this.journey.updateEntry(entryId, userId, asPayload(params.input) as never); + if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); + return entry; + } + + @PluginMethod('journal.deleteEntry', { permission: 'db:write:journal' }) + deleteEntry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'writes'); + const entryId = num(params.entryId, 'entryId'); + this.requireJourneyAddon(); + if (!this.journey.deleteEntry(entryId, userId)) { + throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); + } + return { deleted: true }; + } + + @PluginMethod('journal.createJourney', { permission: 'db:write:journal' }) + createJourney(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'writes'); + const input = asPayload(params.input); + this.requireJourneyAddon(); + const title = typeof input.title === 'string' ? input.title.trim() : ''; + if (!title) throw new BadParams('journal title is required'); + return this.journey.createJourney(userId, { + title, + subtitle: input.subtitle as string | undefined, + trip_ids: input.trip_ids as number[] | undefined, + }); + } + + @PluginMethod('journal.deleteJourney', { permission: 'db:write:journal' }) + deleteJourney(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireJournalUser(ctx, 'writes'); + const journeyId = num(params.journeyId, 'journeyId'); + this.requireJourneyAddon(); + if (!this.journey.deleteJourney(journeyId, userId)) { + throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`); + } + return { deleted: true }; + } + + private requireJournalUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number { + if (ctx.actingUserId === undefined) { + throw new ForbiddenResource(`journal ${kind} require an authenticated user context`); + } + return ctx.actingUserId; + } + + private requireJourneyAddon(): void { + this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey'); + } +} diff --git a/server/src/nest/journey/journey-domain.module.ts b/server/src/nest/journey/journey-domain.module.ts index 2806f4080..de9c2bcbd 100644 --- a/server/src/nest/journey/journey-domain.module.ts +++ b/server/src/nest/journey/journey-domain.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { RealtimeModule } from '../realtime/realtime.module'; import { TrekPhotosModule } from '../photos/trek-photos.module'; import { JourneyDomainService } from './journey-domain.service'; +import { JournalRpc } from './journal.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { JourneyShareService } from './journey-share.service'; /** @@ -16,8 +18,8 @@ import { JourneyShareService } from './journey-share.service'; * single-domain e2e TestingModule can still resolve the broadcast. */ @Module({ - imports: [RealtimeModule, TrekPhotosModule], - providers: [JourneyDomainService, JourneyShareService], + imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule], + providers: [JourneyDomainService, JourneyShareService, JournalRpc], exports: [JourneyDomainService, JourneyShareService], }) export class JourneyDomainModule {} 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 f026e7cdf..0265a3554 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -196,37 +196,7 @@ export class PluginHostDepsFactory { // must live on the same trip (findForeignLinkTarget). Same events as the app. --- // --- Collab reads (collab addon; membership checked by the host). Same hydrated // shapes as the REST GETs, so a collab plugin can finally read what it writes. --- - listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listNotes(tripId) as unknown[]; }, - listCollabPolls: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listPolls(tripId) as unknown[]; }, - listCollabMessages: (tripId, before) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listMessages(tripId, before) as unknown[]; }, // --- Collab content (collab addon). The services validate + self-report errors. --- - canEditCollab: (tripId, userId) => this.canEditTripAs('collab_edit', tripId, userId), - createCollabNote: (tripId, input, actingUserId) => { - requireAddon(ADDON_IDS.COLLAB, 'collab'); - const note = this.collab.createNote(String(tripId), actingUserId, input as never); - this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined); - return note; - }, - createCollabPoll: (tripId, input, actingUserId) => { - requireAddon(ADDON_IDS.COLLAB, 'collab'); - const poll = this.collab.createPoll(String(tripId), actingUserId, input as never); - this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined); - return poll; - }, - voteCollabPoll: (tripId, pollId, optionIndex, actingUserId) => { - requireAddon(ADDON_IDS.COLLAB, 'collab'); - const result = this.collab.votePoll(String(tripId), String(pollId), actingUserId, optionIndex); - if (result.error) throw new BadParams(result.error); - this.realtime.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); - return result.poll; - }, - createCollabMessage: (tripId, text, replyTo, actingUserId) => { - requireAddon(ADDON_IDS.COLLAB, 'collab'); - const result = this.collab.createMessage(String(tripId), actingUserId, text, replyTo ?? null); - if (result.error) throw new BadParams(result.error); - this.realtime.broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); - return result.message; - }, // --- 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. --- // --- Host-mediated notifications. Recipient resolution + channel fan-out + @@ -347,97 +317,12 @@ export class PluginHostDepsFactory { // --- User-scoped addon reads (the acting user's own data across all trips). Each // reuses the same service the addon's REST/MCP path uses; the addon-enabled gate // mirrors the app (a disabled addon has nothing to read). --- - listJournalsForUser: (userId) => { requireAddon(ADDON_IDS.JOURNEY, 'journey'); return this.journey.listJourneys(userId); }, - journalEntriesForUser: (userId, journeyId) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - // listEntries self-gates via canAccessJourney(journeyId, userId) → null if the - // user can't see it (owner/contributor only). - const entries = this.journey.listEntries(journeyId, userId); - if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`); - return entries; - }, - atlasVisitedForUser: (userId) => { - requireAddon(ADDON_IDS.ATLAS, 'atlas'); - return { countries: this.atlas.listVisitedCountries(userId), regions: this.atlas.listManuallyVisitedRegions(userId) }; - }, - atlasBucketForUser: (userId) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return this.atlas.bucketList(userId) as unknown[]; }, - vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.getPlanData(userId); }, - listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.listCollections(userId); }, - getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return this.collections.getCollection(userId, id); }, // --- Collections write. The service self-gates per-collection role (assertAccess/ // assertCanEdit throw status-tagged errors) — map those to the RPC error codes. --- - createCollectionForUser: (userId, input) => { - requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => this.collections.createCollection(userId, input as never)); - }, - updateCollectionForUser: (userId, id, input) => { - requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => this.collections.updateCollection(userId, id, input as never, undefined)); - }, - saveCollectionPlace: (userId, input) => { - requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => this.collections.savePlace(userId, input as never, undefined)); - }, - copyCollectionToTrip: (userId, input) => { - requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - return mapCollectionError(() => this.collections.copyToTrip(userId, input as never)); - }, - deleteCollectionPlace: (userId, placeId) => { - requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); - mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined)); - return { deleted: true }; - }, // --- Atlas write: plain uid-scoped rows, no broadcasts in the service. --- - markCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.markCountry(userId, code); return { visited: true }; }, - unmarkCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.unmarkCountry(userId, code); return { visited: false }; }, - markRegionVisited: (userId, regionCode, regionName, countryCode) => { - requireAddon(ADDON_IDS.ATLAS, 'atlas'); - this.atlas.markRegion(userId, regionCode, regionName, countryCode); - return { visited: true }; - }, - unmarkRegionVisited: (userId, regionCode) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); this.atlas.unmarkRegion(userId, regionCode); return { visited: false }; }, - createBucketItem: (userId, input) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return this.atlas.createBucketItem(userId, input as never); }, - deleteBucketItem: (userId, itemId) => { - requireAddon(ADDON_IDS.ATLAS, 'atlas'); - if (!this.atlas.deleteBucketItem(userId, itemId)) throw new ForbiddenResource(`no bucket item ${itemId} for this user`); - return { deleted: true }; - }, // --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side); // the service broadcasts to plan users itself. --- - vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); }, - vacayToggleCompanyHoliday: (userId, date, note) => { - requireAddon(ADDON_IDS.VACAY, 'vacay'); - return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined); - }, // --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). --- - createJournalEntry: (userId, journeyId, input) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - const entry = this.journey.createEntry(journeyId, userId, input as never); - if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`); - return entry; - }, - updateJournalEntry: (userId, entryId, input) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - const entry = this.journey.updateEntry(entryId, userId, input as never); - if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); - return entry; - }, - deleteJournalEntry: (userId, entryId) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - if (!this.journey.deleteEntry(entryId, userId)) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); - return { deleted: true }; - }, - createJournal: (userId, input) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - const title = typeof (input as { title?: unknown }).title === 'string' ? String((input as { title: string }).title).trim() : ''; - if (!title) throw new BadParams('journal title is required'); - return this.journey.createJourney(userId, { title, subtitle: (input as { subtitle?: string }).subtitle, trip_ids: (input as { trip_ids?: number[] }).trip_ids }); - }, - deleteJournal: (userId, journeyId) => { - requireAddon(ADDON_IDS.JOURNEY, 'journey'); - if (!this.journey.deleteJourney(journeyId, userId)) throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`); - return { deleted: true }; - }, // Day notes are core (no addon) and trip-scoped; membership is enforced by the host. // --- Day notes write (day_edit). The day must belong to the trip; broadcasts the // same dayNote:* events the REST controller emits so open sessions update live. --- diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts index f371d30b6..c688a6dfb 100644 --- a/server/src/nest/plugins/host/rpc-host.ts +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -97,54 +97,21 @@ export interface HostDeps { /** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */ /** Move a trip file to the trash; broadcasts file:deleted. */ // --- Collab reads (membership + collab addon; no separate right, like the REST GETs) --- - listCollabNotes(tripId: number): unknown[]; - listCollabPolls(tripId: number): unknown[]; - listCollabMessages(tripId: number, before: number | undefined): unknown[]; // --- Collab content (the app's collab_edit permission; collab addon gated) --- - canEditCollab(tripId: number, userId: number): boolean; - createCollabNote(tripId: number, input: Record, actingUserId: number): unknown; - createCollabPoll(tripId: number, input: Record, actingUserId: number): unknown; - 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) --- /** 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). */ - journalEntriesForUser(userId: number, journeyId: number): unknown; /** The acting user's visited countries + regions (atlas addon must be enabled). */ - atlasVisitedForUser(userId: number): unknown; /** The acting user's bucket-list items (atlas addon must be enabled). */ - atlasBucketForUser(userId: number): unknown[]; /** The acting user's vacation plan data (vacay addon must be enabled). */ - vacayForUser(userId: number): unknown; /** The acting user's saved-place collections (collections addon must be enabled). */ - listCollectionsForUser(userId: number): unknown; /** One of the acting user's collections by id (collections addon must be enabled). */ - getCollectionForUser(userId: number, id: number): unknown; // --- Collections write (the service enforces per-collection role itself) --- - createCollectionForUser(userId: number, input: Record): unknown; - updateCollectionForUser(userId: number, id: number, input: Record): unknown; - saveCollectionPlace(userId: number, input: Record): unknown; - copyCollectionToTrip(userId: number, input: Record): unknown; - deleteCollectionPlace(userId: number, placeId: number): unknown; // --- Atlas write (all rows are the acting user's own; atlas addon gated) --- - markCountryVisited(userId: number, code: string): unknown; - unmarkCountryVisited(userId: number, code: string): unknown; - markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): unknown; - unmarkRegionVisited(userId: number, regionCode: string): unknown; - createBucketItem(userId: number, input: Record): unknown; - deleteBucketItem(userId: number, itemId: number): unknown; // --- Vacay write (plan resolved from the acting user; vacay addon gated) --- - vacayToggleEntry(userId: number, date: string): unknown; - vacayToggleCompanyHoliday(userId: number, date: string, note: string | undefined): unknown; // --- Journal write (journeyService.canEdit self-gates; journey addon gated) --- - createJournalEntry(userId: number, journeyId: number, input: Record): unknown; - updateJournalEntry(userId: number, entryId: number, input: Record): unknown; - deleteJournalEntry(userId: number, entryId: number): unknown; /** Create/delete a JOURNAL itself (owned by the acting user) — lets an importer * bootstrap the journal it then fills, and clean it up. */ - createJournal(userId: number, input: Record): unknown; - deleteJournal(userId: number, journeyId: number): unknown; /** A trip day's notes (trip-scoped), for `daynotes.list`. */ /** Create a day note (the day must be on the trip); broadcasts dayNote:created. */ /** Update a day note (scoped to the day+trip); broadcasts dayNote:updated. */ @@ -235,185 +202,12 @@ export class PluginRpcHost { // 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. - this.methods.set('collab.createNote', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const actor = this.requireActor(uid, 'collab note'); - const input = asPayload(p.input); - if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required'); - this.requireTripEdit(tripId, actor, deps.canEditCollab); - return deps.createCollabNote(tripId, input, actor); - }); - this.methods.set('collab.createPoll', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const actor = this.requireActor(uid, 'collab poll'); - const input = asPayload(p.input); - if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required'); - if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options'); - this.requireTripEdit(tripId, actor, deps.canEditCollab); - return deps.createCollabPoll(tripId, input, actor); - }); - this.methods.set('collab.votePoll', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const actor = this.requireActor(uid, 'collab poll'); - this.requireTripEdit(tripId, actor, deps.canEditCollab); - return deps.voteCollabPoll(tripId, num(p.pollId, 'pollId'), num(p.optionIndex, 'optionIndex'), actor); - }); - this.methods.set('collab.createMessage', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - const actor = this.requireActor(uid, 'collab message'); - if (typeof p.text !== 'string' || p.text.trim() === '' || p.text.length > 4000) throw new BadParams('message text is required (max 4000 chars)'); - this.requireTripEdit(tripId, actor, deps.canEditCollab); - return deps.createCollabMessage(tripId, p.text, typeof p.replyTo === 'number' ? p.replyTo : undefined, 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 // acting user, not a tripId; the wiring additionally refuses a disabled addon. - if (has('db:read:collab')) { - // Collab reads mirror the REST GETs: membership only (the addon gate lives in - // the wiring), no separate right — the write side already has collab_edit. - this.methods.set('collab.listNotes', (p, uid) => this.tripRead(p, uid, () => deps.listCollabNotes(num(p.tripId, 'tripId')))); - this.methods.set('collab.listPolls', (p, uid) => this.tripRead(p, uid, () => deps.listCollabPolls(num(p.tripId, 'tripId')))); - this.methods.set('collab.listMessages', (p, uid) => - this.tripRead(p, uid, () => deps.listCollabMessages(num(p.tripId, 'tripId'), p.before != null ? num(p.before, 'before') : undefined)), - ); - } - if (has('db:read:journal')) { - this.methods.set('journal.listMine', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context'); - return deps.listJournalsForUser(uid); - }); - // A journey's entries (photos/story/checkins). Journeys are user-scoped, not - // trip-scoped, so the access check is journey membership inside the wiring. - this.methods.set('journal.getEntries', (p, uid) => { - if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context'); - return deps.journalEntriesForUser(uid, num(p.journeyId, 'journeyId')); - }); - } - if (has('db:read:atlas')) { - this.methods.set('atlas.visited', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context'); - return deps.atlasVisitedForUser(uid); - }); - this.methods.set('atlas.bucketList', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context'); - return deps.atlasBucketForUser(uid); - }); - } - if (has('db:read:vacay')) { - this.methods.set('vacay.mine', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('vacay reads require an authenticated user context'); - return deps.vacayForUser(uid); - }); - } - if (has('db:read:collections')) { - this.methods.set('collections.listMine', (_p, uid) => { - if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context'); - return deps.listCollectionsForUser(uid); - }); - // getCollection is user-scoped by the service (it takes the acting user), so a - // plugin can only fetch a collection the acting user owns. - this.methods.set('collections.get', (p, uid) => { - if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context'); - return deps.getCollectionForUser(uid, num(p.id, 'id')); - }); - } - if (has('db:write:collections')) { - // Collections write. The service enforces per-collection role itself - // (owner/admin/editor via assertCanEdit) against the HOST-bound acting user — - // the wiring maps its 403/404 to RESOURCE_FORBIDDEN. Inputs are schema-validated. - const requireUid = (uid: number | undefined): number => { - if (uid === undefined) throw new ForbiddenResource('collection writes require an authenticated user context'); - return uid; - }; - this.methods.set('collections.create', (p, uid) => { - const parsed = collectionCreateRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - return deps.createCollectionForUser(requireUid(uid), parsed.data as Record); - }); - this.methods.set('collections.update', (p, uid) => { - const parsed = collectionUpdateRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - return deps.updateCollectionForUser(requireUid(uid), num(p.id, 'id'), parsed.data as Record); - }); - this.methods.set('collections.savePlace', (p, uid) => { - const parsed = collectionSavePlaceRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - return deps.saveCollectionPlace(requireUid(uid), parsed.data as Record); - }); - this.methods.set('collections.copyToTrip', (p, uid) => { - const parsed = collectionCopyToTripRequestSchema.safeParse(p.input); - if (!parsed.success) throw new BadParams(`invalid copy request: ${parsed.error.issues[0]?.message ?? 'bad input'}`); - return deps.copyCollectionToTrip(requireUid(uid), parsed.data as Record); - }); - this.methods.set('collections.deletePlace', (p, uid) => deps.deleteCollectionPlace(requireUid(uid), num(p.placeId, 'placeId'))); - } - if (has('db:write:atlas')) { - // Atlas write: every row is the acting user's own (visited_countries / - // visited_regions / bucket) — no trip scoping, no cross-tenant surface. - const requireUid = (uid: number | undefined): number => { - if (uid === undefined) throw new ForbiddenResource('atlas writes require an authenticated user context'); - return uid; - }; - const code = (v: unknown, name: string): string => { - if (typeof v !== 'string' || v.trim() === '' || v.length > 8) throw new BadParams(`${name} must be a short code`); - return v.trim().toUpperCase(); - }; - this.methods.set('atlas.markCountry', (p, uid) => deps.markCountryVisited(requireUid(uid), code(p.code, 'code'))); - this.methods.set('atlas.unmarkCountry', (p, uid) => deps.unmarkCountryVisited(requireUid(uid), code(p.code, 'code'))); - this.methods.set('atlas.markRegion', (p, uid) => { - const u = requireUid(uid); - const regionName = typeof p.regionName === 'string' && p.regionName ? p.regionName.slice(0, 128) : String(p.regionCode ?? ''); - return deps.markRegionVisited(u, code(p.regionCode, 'regionCode'), regionName, code(p.countryCode, 'countryCode')); - }); - this.methods.set('atlas.unmarkRegion', (p, uid) => deps.unmarkRegionVisited(requireUid(uid), code(p.regionCode, 'regionCode'))); - this.methods.set('atlas.createBucketItem', (p, uid) => { - const u = requireUid(uid); - const input = asPayload(p.input); - if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required'); - return deps.createBucketItem(u, input); - }); - this.methods.set('atlas.deleteBucketItem', (p, uid) => deps.deleteBucketItem(requireUid(uid), num(p.itemId, 'itemId'))); - } - if (has('db:write:vacay')) { - // Vacay write: the plan is resolved host-side from the acting user's active - // plan — a plugin can never name another plan. toggleEntry only ever toggles - // the ACTING USER's own PTO day. - const requireUid = (uid: number | undefined): number => { - if (uid === undefined) throw new ForbiddenResource('vacay writes require an authenticated user context'); - return uid; - }; - const dateStr = (v: unknown): string => { - if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new BadParams('date must be YYYY-MM-DD'); - return v; - }; - this.methods.set('vacay.toggleEntry', (p, uid) => deps.vacayToggleEntry(requireUid(uid), dateStr(p.date))); - this.methods.set('vacay.toggleCompanyHoliday', (p, uid) => - deps.vacayToggleCompanyHoliday(requireUid(uid), dateStr(p.date), typeof p.note === 'string' ? p.note.slice(0, 256) : undefined)); - } - if (has('db:write:journal')) { - // Journal write: journeyService.canEdit self-gates every call against the - // acting user (owner/contributor) — the wiring maps a refusal to - // RESOURCE_FORBIDDEN. Journeys are user-scoped, not trip-scoped. - const requireUid = (uid: number | undefined): number => { - if (uid === undefined) throw new ForbiddenResource('journal writes require an authenticated user context'); - return uid; - }; - this.methods.set('journal.createEntry', (p, uid) => { - const u = requireUid(uid); - const input = asPayload(p.input); - if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required'); - return deps.createJournalEntry(u, num(p.journeyId, 'journeyId'), input); - }); - this.methods.set('journal.updateEntry', (p, uid) => - deps.updateJournalEntry(requireUid(uid), num(p.entryId, 'entryId'), asPayload(p.input))); - this.methods.set('journal.deleteEntry', (p, uid) => deps.deleteJournalEntry(requireUid(uid), num(p.entryId, 'entryId'))); - this.methods.set('journal.createJourney', (p, uid) => deps.createJournal(requireUid(uid), asPayload(p.input))); - this.methods.set('journal.deleteJourney', (p, uid) => deps.deleteJournal(requireUid(uid), num(p.journeyId, 'journeyId'))); - } + // collab, journal, atlas, vacay and collections now live in their own + // domains' *.rpc.ts, each carrying its addon gate with it. // costs.* now lives in src/nest/budget/costs.rpc.ts. diff --git a/server/src/nest/vacay/vacay.module.ts b/server/src/nest/vacay/vacay.module.ts index 954b10d21..c6102543c 100644 --- a/server/src/nest/vacay/vacay.module.ts +++ b/server/src/nest/vacay/vacay.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { VacayController } from './vacay.controller'; import { VacayService } from './vacay.service'; +import { VacayRpc } from './vacay.rpc'; +import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { VacayMcp } from './vacay.mcp'; import { AuthModule } from '../auth/auth.module'; @@ -10,9 +12,9 @@ import { AuthModule } from '../auth/auth.module'; * VacayMcp carries the DI-discovered MCP tools/resources. */ @Module({ - imports: [AuthModule], + imports: [AuthModule, PluginGuardsModule], controllers: [VacayController], - providers: [VacayService, VacayMcp], + providers: [VacayService, VacayMcp, VacayRpc], exports: [VacayService], }) export class VacayModule {} diff --git a/server/src/nest/vacay/vacay.rpc.ts b/server/src/nest/vacay/vacay.rpc.ts new file mode 100644 index 000000000..422f386c6 --- /dev/null +++ b/server/src/nest/vacay/vacay.rpc.ts @@ -0,0 +1,61 @@ +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 type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; +import { ADDON_IDS } from '../../addons'; +import { VacayService } from './vacay.service'; + +/** + * The vacay surface a plugin may reach (#plugins). + * + * The plan is always resolved host-side from the acting user's ACTIVE plan, so a + * plugin can never name someone else's plan, and toggleEntry only ever moves the + * acting user's own PTO day. The service broadcasts to the plan's users itself. + */ +@PluginController() +export class VacayRpc { + constructor( + private readonly vacay: VacayService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('vacay.mine', { permission: 'db:read:vacay' }) + mine(_params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireVacayUser(ctx, 'reads'); + this.requireVacayAddon(); + return this.vacay.getPlanData(userId); + } + + @PluginMethod('vacay.toggleEntry', { permission: 'db:write:vacay' }) + toggleEntry(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireVacayUser(ctx, 'writes'); + const date = this.dateStr(params.date); + this.requireVacayAddon(); + return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); + } + + @PluginMethod('vacay.toggleCompanyHoliday', { permission: 'db:write:vacay' }) + toggleCompanyHoliday(params: Record, ctx: PluginRpcContext): unknown { + const userId = this.requireVacayUser(ctx, 'writes'); + const date = this.dateStr(params.date); + const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined; + this.requireVacayAddon(); + return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined); + } + + private requireVacayUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number { + if (ctx.actingUserId === undefined) { + throw new ForbiddenResource(`vacay ${kind} require an authenticated user context`); + } + return ctx.actingUserId; + } + + private requireVacayAddon(): void { + this.guards.requireAddon(ADDON_IDS.VACAY, 'vacay'); + } + + private dateStr(value: unknown): string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadParams('date must be YYYY-MM-DD'); + return value; + } +} diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index 426c82ab9..0ed381008 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -15,6 +15,11 @@ import { TripsRpc } from '../../src/nest/trips/trips.rpc'; import { CostsRpc } from '../../src/nest/budget/costs.rpc'; import { ReservationsRpc } from '../../src/nest/reservations/reservations.rpc'; import { AccommodationsRpc } from '../../src/nest/days/accommodations.rpc'; +import { CollabRpc } from '../../src/nest/collab/collab.rpc'; +import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc'; +import { VacayRpc } from '../../src/nest/vacay/vacay.rpc'; +import { JournalRpc } from '../../src/nest/journey/journal.rpc'; +import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; /** * The stubbed HostDeps every plugin router test builds on. It used to live inside @@ -204,5 +209,10 @@ export function allRpcControllers(): object[] { new CostsRpc(anyService(), anyService(), anyService(), anyService()), new ReservationsRpc(anyService(), anyService(), anyService()), new AccommodationsRpc(anyService(), anyService(), anyService()), + new CollabRpc(anyService(), anyService(), anyService()), + new AtlasRpc(anyService(), anyService()), + new VacayRpc(anyService(), anyService()), + new JournalRpc(anyService(), anyService()), + new CollectionsRpc(anyService(), anyService()), ]; } diff --git a/server/tests/unit/files/files.rpc.test.ts b/server/tests/unit/files/files.rpc.test.ts index 2f335b5c3..10b96e89a 100644 --- a/server/tests/unit/files/files.rpc.test.ts +++ b/server/tests/unit/files/files.rpc.test.ts @@ -232,6 +232,26 @@ describe('FilesRpc writes', () => { } }); + it('FILES-RPC-016c link ids are stringified, and absent ones become null', async () => { + const f = build(); + await f.host('db:write:files').dispatch(req('files.createLink', { tripId: 1, fileId: 2, opts: { reservation_id: 9, assignment_id: 3 } }), 42); + expect(f.files.createFileLink).toHaveBeenCalledWith(2, { reservation_id: '9', assignment_id: '3', place_id: null }); + }); + + it('FILES-RPC-016d create stores its optional link ids as strings', async () => { + const f = build(); + await f.host('db:write:files').dispatch( + req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: b64('x'), place_id: 7, reservation_id: 9, description: 'd' } }), 42, + ); + expect(f.files.createFile).toHaveBeenCalledWith(1, expect.anything(), 42, { place_id: '7', reservation_id: '9', description: 'd' }); + }); + + it('FILES-RPC-016e a file with no recorded size or mimetype still reads', async () => { + const f = build({ file: { filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: null, file_size: null, deleted_at: null } }); + const res = await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42); + expect((res as { result: { mimetype: string } }).result.mimetype).toBe('application/octet-stream'); + }); + it('FILES-RPC-017 the class is listed in its module providers', () => { expect(Reflect.getMetadata('providers', FilesModule) as unknown[]).toContain(FilesRpc); }); diff --git a/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts b/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts new file mode 100644 index 000000000..4378cf686 --- /dev/null +++ b/server/tests/unit/plugins/addon-domains-errors.rpc.test.ts @@ -0,0 +1,243 @@ +/** + * The refusal paths of the five addon-gated domains: no bound user, invalid input, + * and the service saying no. + * + * The happy paths and the addon gates live in addon-gates.rpc.test.ts and the domain + * suites; this file is about what happens when something is wrong, because those are + * the branches a move like this leaves untested by default. + */ +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 { CollabRpc } from '../../../src/nest/collab/collab.rpc'; +import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc'; +import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc'; +import { JournalRpc } from '../../../src/nest/journey/journal.rpc'; +import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc'; +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 err = (r: unknown) => (r as RpcError).error; + +/** A status-tagged error, the shape CollectionsService throws. */ +function statusError(status: number, message: string) { + return Object.assign(new Error(message), { status }); +} + +function build(overrides: { + collab?: Record; + atlas?: Record; + vacay?: Record; + journey?: Record; + collections?: Record; +} = {}) { + const db = { + canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })), + prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })), + } as unknown as DatabaseService; + const guards = new PluginGuards( + db, + { checkPermission: vi.fn(() => true) } as unknown as PermissionsService, + { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, + ); + const realtime = { broadcast: vi.fn() } as never; + const registry = createTestPluginRegistry([ + new CollabRpc({ createNote: vi.fn(() => ({})), createPoll: vi.fn(() => ({})), votePoll: vi.fn(() => ({ error: null, poll: {} })), createMessage: vi.fn(() => ({ error: null, message: {} })), listNotes: vi.fn(() => []), listPolls: vi.fn(() => []), listMessages: vi.fn(() => []), ...overrides.collab } as never, realtime, guards), + new AtlasRpc({ listVisitedCountries: vi.fn(() => []), listManuallyVisitedRegions: vi.fn(() => []), bucketList: vi.fn(() => []), markCountry: vi.fn(), unmarkCountry: vi.fn(), markRegion: vi.fn(), unmarkRegion: vi.fn(), createBucketItem: vi.fn(() => ({})), deleteBucketItem: vi.fn(() => true), ...overrides.atlas } as never, guards), + new VacayRpc({ getPlanData: vi.fn(() => ({})), getActivePlanId: vi.fn(() => 1), toggleEntry: vi.fn(() => ({})), toggleCompanyHoliday: vi.fn(() => ({})), ...overrides.vacay } as never, guards), + new JournalRpc({ listJourneys: vi.fn(() => []), listEntries: vi.fn(() => []), createEntry: vi.fn(() => ({})), updateEntry: vi.fn(() => ({})), deleteEntry: vi.fn(() => true), createJourney: vi.fn(() => ({})), deleteJourney: vi.fn(() => true), ...overrides.journey } as never, guards), + new CollectionsRpc({ listCollections: vi.fn(() => []), getCollection: vi.fn(() => ({})), createCollection: vi.fn(() => ({})), updateCollection: vi.fn(() => ({})), savePlace: vi.fn(() => ({})), copyToTrip: vi.fn(() => ({})), deletePlace: vi.fn(() => undefined), ...overrides.collections } as never, guards), + ]); + const granted = new Set([ + 'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal', + 'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:write:vacay', + 'db:read:collections', 'db:write:collections', + ]); + return new PluginRpcHost('p', granted, makeDeps(), registry); +} + +/** Every user-scoped method, with the refusal it owes a job or onLoad. */ +const USERLESS: Array<[string, Record, string]> = [ + ['journal.listMine', {}, 'journal reads require an authenticated user context'], + ['journal.getEntries', { journeyId: 7 }, 'journal reads require an authenticated user context'], + ['journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }, 'journal writes require an authenticated user context'], + ['journal.updateEntry', { entryId: 1, input: {} }, 'journal writes require an authenticated user context'], + ['journal.deleteEntry', { entryId: 1 }, 'journal writes require an authenticated user context'], + ['journal.createJourney', { input: { title: 'J' } }, 'journal writes require an authenticated user context'], + ['journal.deleteJourney', { journeyId: 7 }, 'journal writes require an authenticated user context'], + ['atlas.visited', {}, 'atlas reads require an authenticated user context'], + ['atlas.bucketList', {}, 'atlas reads require an authenticated user context'], + ['atlas.markCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'], + ['atlas.unmarkCountry', { code: 'JP' }, 'atlas writes require an authenticated user context'], + ['atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }, 'atlas writes require an authenticated user context'], + ['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas writes require an authenticated user context'], + ['atlas.createBucketItem', { input: { name: 'K' } }, 'atlas writes require an authenticated user context'], + ['atlas.deleteBucketItem', { itemId: 1 }, 'atlas writes require an authenticated user context'], + ['vacay.mine', {}, 'vacay reads require an authenticated user context'], + ['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'], + ['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay writes require an authenticated user context'], + ['collections.listMine', {}, 'collection reads require an authenticated user context'], + ['collections.get', { id: 1 }, 'collection reads require an authenticated user context'], + ['collections.create', { input: { name: 'C' } }, 'collection writes require an authenticated user context'], + ['collections.update', { id: 1, input: { name: 'C' } }, 'collection writes require an authenticated user context'], + ['collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }, 'collection writes require an authenticated user context'], + ['collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }, 'collection writes require an authenticated user context'], + ['collections.deletePlace', { placeId: 1 }, 'collection writes require an authenticated user context'], + ['collab.createNote', { tripId: 1, input: { title: 'n' } }, 'collab note writes require an authenticated user context'], + ['collab.createPoll', { tripId: 1, input: { question: 'q', options: ['a', 'b'] } }, 'collab poll writes require an authenticated user context'], + ['collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }, 'collab poll writes require an authenticated user context'], + ['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab message writes require an authenticated user context'], +]; + +describe('addon-gated domains refuse a job or onLoad', () => { + for (const [method, params, message] of USERLESS) { + it(`ADDONERR-userless-${method}`, async () => { + const res = await build().dispatch(req(method, params), undefined); + expect(err(res).code).toBe('RESOURCE_FORBIDDEN'); + expect(err(res).message).toBe(message); + }); + } +}); + +describe('addon-gated domains validate their input', () => { + it('ADDONERR-001 collab refuses a blank note title, a thin poll and an oversized message', async () => { + const host = build(); + expect(err(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: ' ' } }), 42)).message).toBe('note title is required'); + expect(err(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: ' ', options: ['a', 'b'] } }), 42)).message).toBe('poll question is required'); + expect(err(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'q', options: ['a'] } }), 42)).message).toBe('a poll needs at least two options'); + expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'x'.repeat(4001) }), 42)).message).toBe('message text is required (max 4000 chars)'); + expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: ' ' }), 42)).message).toBe('message text is required (max 4000 chars)'); + }); + + it('ADDONERR-002 the collab service reports its own failures as BAD_PARAMS', async () => { + const host = build({ collab: { votePoll: vi.fn(() => ({ error: 'poll is closed' })), createMessage: vi.fn(() => ({ error: 'reply target is gone' })) } }); + expect(err(await host.dispatch(req('collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'poll is closed' }); + expect(err(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'reply target is gone' }); + }); + + it('ADDONERR-003 atlas rejects a code that is empty or too long', async () => { + const host = build(); + expect(err(await host.dispatch(req('atlas.markCountry', { code: '' }), 42)).message).toBe('code must be a short code'); + expect(err(await host.dispatch(req('atlas.markCountry', { code: 'TOOLONGCODE' }), 42)).message).toBe('code must be a short code'); + expect(err(await host.dispatch(req('atlas.markRegion', { regionCode: 'JP-13', countryCode: '' }), 42)).message).toBe('countryCode must be a short code'); + expect(err(await host.dispatch(req('atlas.createBucketItem', { input: { name: ' ' } }), 42)).message).toBe('bucket item name is required'); + }); + + it('ADDONERR-004 a missing bucket item is refused, naming it', async () => { + const host = build({ atlas: { deleteBucketItem: vi.fn(() => false) } }); + expect(err(await host.dispatch(req('atlas.deleteBucketItem', { itemId: 404 }), 42)).message).toBe('no bucket item 404 for this user'); + }); + + it('ADDONERR-005 vacay insists on an ISO date', async () => { + const host = build(); + expect(err(await host.dispatch(req('vacay.toggleEntry', { date: '01.01.2027' }), 42)).message).toBe('date must be YYYY-MM-DD'); + expect(err(await host.dispatch(req('vacay.toggleCompanyHoliday', { date: 42 }), 42)).message).toBe('date must be YYYY-MM-DD'); + }); + + it('ADDONERR-006 journal needs an entry_date and a journal title', async () => { + const host = build(); + expect(err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: {} }), 42)).message).toBe('entry_date is required'); + expect(err(await host.dispatch(req('journal.createJourney', { input: { title: ' ' } }), 42)).message).toBe('journal title is required'); + }); + + it('ADDONERR-007 an unreadable or uneditable journey is RESOURCE_FORBIDDEN', async () => { + const host = build({ + journey: { + listEntries: vi.fn(() => null), + createEntry: vi.fn(() => null), + updateEntry: vi.fn(() => null), + deleteEntry: vi.fn(() => false), + deleteJourney: vi.fn(() => false), + }, + }); + expect(err(await host.dispatch(req('journal.getEntries', { journeyId: 7 }), 42)).message).toBe('no access to journey 7'); + expect(err(await host.dispatch(req('journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }), 42)).message).toBe('no editable journey 7 for this user'); + expect(err(await host.dispatch(req('journal.updateEntry', { entryId: 3, input: {} }), 42)).message).toBe('no editable journal entry 3 for this user'); + expect(err(await host.dispatch(req('journal.deleteEntry', { entryId: 3 }), 42)).message).toBe('no editable journal entry 3 for this user'); + expect(err(await host.dispatch(req('journal.deleteJourney', { journeyId: 7 }), 42)).message).toBe('no deletable journal 7 for this user'); + }); + + it('ADDONERR-008 collections map the service status onto the RPC error taxonomy', async () => { + // 403/404 become RESOURCE_FORBIDDEN, 400/409 become BAD_PARAMS. Without this the + // plugin would see an opaque HOST_ERROR and could not tell the cases apart. + const forbidden = build({ collections: { createCollection: vi.fn(() => { throw statusError(403, 'not your collection'); }) } }); + expect(err(await forbidden.dispatch(req('collections.create', { input: { name: 'C' } }), 42))).toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'not your collection' }); + + const missing = build({ collections: { updateCollection: vi.fn(() => { throw statusError(404, 'no such collection'); }) } }); + expect(err(await missing.dispatch(req('collections.update', { id: 1, input: { name: 'C' } }), 42))).toMatchObject({ code: 'RESOURCE_FORBIDDEN' }); + + const conflict = build({ collections: { savePlace: vi.fn(() => { throw statusError(409, 'already saved'); }) } }); + expect(err(await conflict.dispatch(req('collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }), 42))).toMatchObject({ code: 'BAD_PARAMS', message: 'already saved' }); + + const badRequest = build({ collections: { copyToTrip: vi.fn(() => { throw statusError(400, 'nothing to copy'); }) } }); + expect(err(await badRequest.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }), 42))).toMatchObject({ code: 'BAD_PARAMS' }); + }); + + it('ADDONERR-009 an untagged collections failure stays a HOST_ERROR', async () => { + // Only the known statuses are translated; anything else keeps its own taxonomy + // rather than being dressed up as a permission problem. + const host = build({ collections: { deletePlace: vi.fn(() => { throw new Error('disk on fire'); }) } }); + expect(err(await host.dispatch(req('collections.deletePlace', { placeId: 1 }), 42))).toMatchObject({ code: 'HOST_ERROR', message: 'disk on fire' }); + }); + + it('ADDONERR-010 collections reject a payload the shared schema refuses', async () => { + const host = build(); + expect(err(await host.dispatch(req('collections.create', { input: {} }), 42)).code).toBe('BAD_PARAMS'); + expect(err(await host.dispatch(req('collections.update', { id: 1, input: { name: 42 } }), 42)).code).toBe('BAD_PARAMS'); + expect(err(await host.dispatch(req('collections.savePlace', { input: {} }), 42)).code).toBe('BAD_PARAMS'); + expect(err(await host.dispatch(req('collections.copyToTrip', { input: { trip_id: 1, place_ids: [] } }), 42)).code).toBe('BAD_PARAMS'); + }); + + it('ADDONERR-010b an update or savePlace with only some fields still passes', async () => { + const updateCollection = vi.fn(() => ({})); + const host = build({ collections: { updateCollection } }); + await host.dispatch(req('collections.update', { id: 1, input: { description: 'only this' } }), 42); + expect(updateCollection).toHaveBeenCalled(); + }); + + it('ADDONERR-010c a journal entry update carries a non-object input through asPayload', async () => { + const updateEntry = vi.fn(() => ({ id: 1 })); + const host = build({ journey: { updateEntry } }); + // asPayload wraps a non-object in { value }, which is the published contract. + await host.dispatch(req('journal.updateEntry', { entryId: 1, input: 'note' }), 42); + expect(updateEntry).toHaveBeenCalledWith(1, 42, { value: 'note' }); + }); + + it('ADDONERR-010d a company holiday without a note passes undefined, not an empty string', async () => { + const toggleCompanyHoliday = vi.fn(() => ({})); + const host = build({ vacay: { toggleCompanyHoliday } }); + await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01' }), 42); + expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined); + }); + + it('ADDONERR-010e a collab message without a replyTo passes null', async () => { + const createMessage = vi.fn(() => ({ error: null, message: {} })); + const host = build({ collab: { createMessage } }); + await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42); + expect(createMessage).toHaveBeenCalledWith('1', 42, 'hi', null); + await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi', replyTo: 5 }), 42); + expect(createMessage).toHaveBeenLastCalledWith('1', 42, 'hi', 5); + }); + + it('ADDONERR-010f listMessages passes the before cursor only when given', async () => { + const listMessages = vi.fn(() => []); + const host = build({ collab: { listMessages } }); + await host.dispatch(req('collab.listMessages', { tripId: 1 }), 42); + expect(listMessages).toHaveBeenCalledWith(1, undefined); + await host.dispatch(req('collab.listMessages', { tripId: 1, before: 9 }), 42); + expect(listMessages).toHaveBeenLastCalledWith(1, 9); + }); + + it('ADDONERR-011 a region without a name falls back to its code', async () => { + const markRegion = vi.fn(); + const host = build({ atlas: { markRegion } }); + await host.dispatch(req('atlas.markRegion', { regionCode: 'jp-13', countryCode: 'jp' }), 42); + // Codes are upper-cased on the way in, and the name defaults to the code. + expect(markRegion).toHaveBeenCalledWith(42, 'JP-13', 'jp-13', 'JP'); + }); +}); diff --git a/server/tests/unit/plugins/addon-gates.rpc.test.ts b/server/tests/unit/plugins/addon-gates.rpc.test.ts new file mode 100644 index 000000000..54c17f857 --- /dev/null +++ b/server/tests/unit/plugins/addon-gates.rpc.test.ts @@ -0,0 +1,148 @@ +/** + * The addon gates of the migrated plugin surface, checked as one table. + * + * These gates used to live inside the deps factory's closure rather than in the + * router, which made them the single most likely thing to lose in the decorator + * migration: a handler that calls its service directly still works, just without the + * addon check, and neither the compiler nor the router tests would notice. The plan + * calls it out as the most probable security regression of the whole rollout. + * + * So every addon-gated method gets the same two assertions: with the addon off it is + * refused with that addon's message, and the underlying service is never touched. + */ +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 { CollabRpc } from '../../../src/nest/collab/collab.rpc'; +import { AtlasRpc } from '../../../src/nest/atlas/atlas.rpc'; +import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc'; +import { JournalRpc } from '../../../src/nest/journey/journal.rpc'; +import { CollectionsRpc } from '../../../src/nest/collections/collections.rpc'; +import { CostsRpc } from '../../../src/nest/budget/costs.rpc'; +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'; + +vi.mock('../../../src/nest/trips/trips.bridge', () => ({ listTripsForUser: vi.fn(() => [{ id: 1 }]) })); + +const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); + +/** Records every service call, so "the service was never touched" is checkable. */ +function spyService(calls: string[], name: string) { + return new Proxy( + {}, + { + get: (_t, prop) => (...args: unknown[]) => { + calls.push(`${name}.${String(prop)}`); + // Shapes the handlers destructure on the happy path. + if (prop === 'votePoll' || prop === 'createMessage') return { error: null, poll: {}, message: {} }; + if (prop === 'listEntries') return []; + if (prop === 'getActivePlanId') return 1; + if (String(prop).startsWith('delete')) return true; + if (String(prop).startsWith('list')) return []; + return { id: 1 }; + }, + }, + ) as never; +} + +/** Everything granted, every trip accessible, every permission allowed, addons OFF. */ +function build(addonOn: boolean) { + const calls: string[] = []; + const db = { + canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })), + prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })), + } as unknown as DatabaseService; + const guards = new PluginGuards( + db, + { checkPermission: vi.fn(() => true) } as unknown as PermissionsService, + { isAddonEnabled: vi.fn(() => addonOn) } as unknown as AddonsService, + ); + const registry = createTestPluginRegistry([ + new CollabRpc(spyService(calls, 'collab'), { broadcast: vi.fn() } as never, guards), + new AtlasRpc(spyService(calls, 'atlas'), guards), + new VacayRpc(spyService(calls, 'vacay'), guards), + new JournalRpc(spyService(calls, 'journey'), guards), + new CollectionsRpc(spyService(calls, 'collections'), guards), + new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards), + ]); + const granted = new Set([ + 'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal', + 'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:write:vacay', + 'db:read:collections', 'db:write:collections', 'db:read:costs', 'db:write:costs', + ]); + return { calls, host: new PluginRpcHost('p', granted, makeDeps(), registry) }; +} + +/** Every addon-gated method with a payload that reaches its gate. */ +const GATED: Array<[string, Record, string]> = [ + ['collab.listNotes', { tripId: 1 }, 'collab'], + ['collab.listPolls', { tripId: 1 }, 'collab'], + ['collab.listMessages', { tripId: 1 }, 'collab'], + ['collab.createNote', { tripId: 1, input: { title: 'n' } }, 'collab'], + ['collab.createPoll', { tripId: 1, input: { question: 'q?', options: ['a', 'b'] } }, 'collab'], + ['collab.votePoll', { tripId: 1, pollId: 1, optionIndex: 0 }, 'collab'], + ['collab.createMessage', { tripId: 1, text: 'hi' }, 'collab'], + ['journal.listMine', {}, 'journey'], + ['journal.getEntries', { journeyId: 7 }, 'journey'], + ['journal.createEntry', { journeyId: 7, input: { entry_date: '2027-01-01' } }, 'journey'], + ['journal.updateEntry', { entryId: 1, input: {} }, 'journey'], + ['journal.deleteEntry', { entryId: 1 }, 'journey'], + ['journal.createJourney', { input: { title: 'J' } }, 'journey'], + ['journal.deleteJourney', { journeyId: 7 }, 'journey'], + ['atlas.visited', {}, 'atlas'], + ['atlas.bucketList', {}, 'atlas'], + ['atlas.markCountry', { code: 'JP' }, 'atlas'], + ['atlas.unmarkCountry', { code: 'JP' }, 'atlas'], + ['atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }, 'atlas'], + ['atlas.unmarkRegion', { regionCode: 'JP-13' }, 'atlas'], + ['atlas.createBucketItem', { input: { name: 'Kyoto' } }, 'atlas'], + ['atlas.deleteBucketItem', { itemId: 1 }, 'atlas'], + ['vacay.mine', {}, 'vacay'], + ['vacay.toggleEntry', { date: '2027-01-01' }, 'vacay'], + ['vacay.toggleCompanyHoliday', { date: '2027-01-01' }, 'vacay'], + ['collections.listMine', {}, 'collections'], + ['collections.get', { id: 1 }, 'collections'], + ['collections.create', { input: { name: 'C' } }, 'collections'], + ['collections.update', { id: 1, input: { name: 'C' } }, 'collections'], + ['collections.savePlace', { input: { collection_id: 1, name: 'P', lat: 1, lng: 2 } }, 'collections'], + ['collections.copyToTrip', { input: { trip_id: 1, place_ids: [1] } }, 'collections'], + ['collections.deletePlace', { placeId: 1 }, 'collections'], + ['costs.getByTrip', { tripId: 1 }, 'costs'], + ['costs.listMine', {}, 'costs'], + ['costs.create', { tripId: 1, input: { name: 'H', price: 1 } }, 'costs'], + ['costs.update', { tripId: 1, itemId: 1, input: { name: 'H' } }, 'costs'], + ['costs.delete', { tripId: 1, itemId: 1 }, 'costs'], +]; + +describe('every addon-gated plugin method refuses when its addon is off', () => { + for (const [method, params, addon] of GATED) { + it(`ADDONGATE-${method} is refused with "the ${addon} addon is disabled"`, async () => { + const f = build(false); + const res = (await f.host.dispatch(req(method, params), 42)) as RpcError; + expect(res.ok).toBe(false); + expect(res.error.code).toBe('RESOURCE_FORBIDDEN'); + expect(res.error.message).toBe(`the ${addon} addon is disabled`); + // The gate has to come before the service, not after it. + expect(f.calls).toEqual([]); + }); + } + + it('ADDONGATE-covers-every-gated-method', () => { + // Guards the table itself: if a later PR adds an addon-gated method without a row + // here, this number is the reminder. + expect(GATED).toHaveLength(37); + }); + + it('ADDONGATE-the-same-calls-succeed-with-the-addon-on', async () => { + const f = build(true); + for (const [method, params] of GATED) { + const res = await f.host.dispatch(req(method, params), 42); + expect(res.ok, `${method} should pass with the addon on`).toBe(true); + } + expect(f.calls.length).toBeGreaterThan(0); + }); +}); 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 7a4b11e1f..899dce03e 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -474,32 +474,7 @@ describe('host-deps factory — planner write + metadata deps', () => { }); }); -describe('host-deps factory — reservations, day notes, cross-trip + addon reads (Waves 1-5)', () => { - const host = (...perms: string[]) => createRealRpcHost('w15', 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); - isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); - }); - afterAll(() => closePluginDataDb('w15')); - - // daynotes.* left this factory with the decorator migration; the cases now run in - // tests/unit/days/day-notes.rpc.test.ts against DayNotesRpc. - - it('addon reads delegate, and a disabled addon is refused', async () => { - const h = host('db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:collections'); - expect((await call(h, 'journal.listMine', {})).ok).toBe(true); - expect((await call(h, 'atlas.visited', {})).ok).toBe(true); - expect((await call(h, 'vacay.mine', {})).ok).toBe(true); - expect((await call(h, 'collections.listMine', {})).ok).toBe(true); - expect((await call(h, 'collections.get', { id: 1 })).ok).toBe(true); - isAddonEnabled.mockReturnValue(false); - expect((await call(h, 'journal.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN'); - expect((await call(h, 'collections.listMine', {})).error.code).toBe('RESOURCE_FORBIDDEN'); - }); -}); +// The 'reservations, day notes, cross-trip + addon reads' suite is gone: every case in it moved onto the decorators. // The packing write deps left this factory with the decorator migration. The #858 // transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the @@ -509,81 +484,9 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read // 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)) - // 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); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true) }) - afterAll(() => closePluginDataDb('w2')) +// The 'Wave 2 wiring' suite is gone: every case in it moved onto the decorators. - it('atlas writes delegate uid-scoped; disabled addon refused; missing bucket item forbidden', async () => { - const h = host('db:write:atlas') - expect((await call(h, 'atlas.markCountry', { code: 'JP' })).ok).toBe(true) - expect((await call(h, 'atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' })).ok).toBe(true) - expect((await call(h, 'atlas.unmarkRegion', { regionCode: 'JP-13' })).ok).toBe(true) - expect((await call(h, 'atlas.createBucketItem', { input: { name: 'Kyoto' } })).ok).toBe(true) - expect((await call(h, 'atlas.deleteBucketItem', { itemId: 110 })).ok).toBe(true) - expect(((await call(h, 'atlas.deleteBucketItem', { itemId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - isAddonEnabled.mockReturnValue(false) - expect(((await call(h, 'atlas.markCountry', { code: 'JP' })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - }) - - it('vacay writes resolve the plan host-side from the acting user', async () => { - const h = host('db:write:vacay') - const r = await call(h, 'vacay.toggleEntry', { date: '2026-08-01' }) - expect(r.ok).toBe(true) - expect(r.result).toMatchObject({ uid: 5, planId: 77 }) // uid + the user's own active plan, never a plugin-named one - expect((await call(h, 'vacay.toggleCompanyHoliday', { date: '2026-12-24' })).ok).toBe(true) - }) - - it('journal writes map an uneditable journey/entry to RESOURCE_FORBIDDEN', async () => { - const h = host('db:write:journal') - expect((await call(h, 'journal.createEntry', { journeyId: 1, input: { entry_date: '2026-08-01' } })).ok).toBe(true) - expect(((await call(h, 'journal.createEntry', { journeyId: 99, input: { entry_date: '2026-08-01' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - expect((await call(h, 'journal.updateEntry', { entryId: 120, input: { story: 'x' } })).ok).toBe(true) - expect(((await call(h, 'journal.updateEntry', { entryId: 99, input: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - expect((await call(h, 'journal.deleteEntry', { entryId: 120 })).ok).toBe(true) - expect(((await call(h, 'journal.deleteEntry', { entryId: 99 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - }) - - it('collections writes map the service 403/404 to RESOURCE_FORBIDDEN', async () => { - const h = host('db:write:collections') - expect((await call(h, 'collections.create', { input: { name: 'Tokyo eats' } })).ok).toBe(true) - expect((await call(h, 'collections.update', { id: 1, input: { name: 'Renamed' } })).ok).toBe(true) - expect(((await call(h, 'collections.update', { id: 99, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // viewer-only 403 - expect(((await call(h, 'collections.update', { id: 404, input: { name: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') // invisible 404 - expect((await call(h, 'collections.savePlace', { input: { collection_id: 1, name: 'Ramen' } })).ok).toBe(true) - expect((await call(h, 'collections.copyToTrip', { input: { trip_id: 1, place_ids: [101] } })).ok).toBe(true) - expect((await call(h, 'collections.deletePlace', { placeId: 101 })).ok).toBe(true) - expect(((await call(h, 'collections.deletePlace', { placeId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - }) -}) - -describe('host-deps factory — Wave 3 wiring (files write / collab / member-add)', () => { - const host = (...perms: string[]) => createRealRpcHost('w3', 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); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() }) - afterAll(() => closePluginDataDb('w3')) - - // The files write deps left this factory with the decorator migration; the cases - // now run in tests/unit/files/files.rpc.test.ts against FilesRpc. - it('collab writes delegate + broadcast; service errors map to BAD_PARAMS; addon gated', async () => { - const h = host('db:write:collab') - expect((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'Ideas' } })).ok).toBe(true) - expect(broadcast.mock.calls.some((c) => c[1] === 'collab:note:created')).toBe(true) - expect((await call(h, 'collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A', 'B'] } })).ok).toBe(true) - expect((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 0 })).ok).toBe(true) - expect(((await call(h, 'collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 9 })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS') - expect((await call(h, 'collab.createMessage', { tripId: 1, text: 'hi' })).ok).toBe(true) - expect(((await call(h, 'collab.createMessage', { tripId: 1, text: 'toolong' })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS') - isAddonEnabled.mockReturnValue(false) - expect(((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'x' } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN') - }) - -}) +// The 'Wave 3 wiring' suite is gone: every case in it moved onto the decorators. describe('host-deps factory — Wave 4 wiring (notify / ai)', () => { const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms)) diff --git a/server/tests/unit/plugins/rpc-coverage.test.ts b/server/tests/unit/plugins/rpc-coverage.test.ts index 2de6fa4d0..026b009fd 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(61); - expect(legacy.size).toBe(total - 61); + expect(fromRegistry.size).toBe(93); + expect(legacy.size).toBe(total - 93); }); it('RPCLEDGER-006 the migrated methods are exactly the ones this PR claims', () => { @@ -63,7 +63,29 @@ describe('plugin RPC coverage ledger', () => { 'accommodations.create', 'accommodations.delete', 'accommodations.update', + 'atlas.bucketList', + 'atlas.createBucketItem', + 'atlas.deleteBucketItem', + 'atlas.markCountry', + 'atlas.markRegion', + 'atlas.unmarkCountry', + 'atlas.unmarkRegion', + 'atlas.visited', 'categories.list', + 'collab.createMessage', + 'collab.createNote', + 'collab.createPoll', + 'collab.listMessages', + 'collab.listNotes', + 'collab.listPolls', + 'collab.votePoll', + 'collections.copyToTrip', + 'collections.create', + 'collections.deletePlace', + 'collections.get', + 'collections.listMine', + 'collections.savePlace', + 'collections.update', 'costs.create', 'costs.delete', 'costs.getByTrip', @@ -84,6 +106,13 @@ describe('plugin RPC coverage ledger', () => { 'files.update', 'itinerary.assign', 'itinerary.unassign', + 'journal.createEntry', + 'journal.createJourney', + 'journal.deleteEntry', + 'journal.deleteJourney', + 'journal.getEntries', + 'journal.listMine', + 'journal.updateEntry', 'packing.create', 'packing.createBag', 'packing.delete', @@ -120,6 +149,9 @@ describe('plugin RPC coverage ledger', () => { 'trips.members', 'trips.removeMember', 'trips.update', + 'vacay.mine', + 'vacay.toggleCompanyHoliday', + 'vacay.toggleEntry', 'weather.get', ]); }); diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts index 78d0b3c67..aa8eb9b3a 100644 --- a/server/tests/unit/plugins/rpc-host.test.ts +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -118,83 +118,15 @@ describe('PluginRpcHost — capability enforcement', () => { }); // The costs, reservation and accommodation cases moved to their domain suites. - it('user-scoped addon reads (journal/atlas/vacay) need a bound user; a job is refused', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:journal', 'db:read:atlas', 'db:read:vacay']), deps); - expect(ok(await host.dispatch(req('journal.listMine'), 42))).toBe(true); - expect(ok(await host.dispatch(req('atlas.visited'), 42))).toBe(true); - expect(ok(await host.dispatch(req('vacay.mine'), 42))).toBe(true); - for (const m of ['journal.listMine', 'atlas.visited', 'vacay.mine']) { - expect((await host.dispatch(req(m), undefined)).ok).toBe(false); - } - }); - + // The addon-gated domains moved to their own suites; addon-gates.rpc.test.ts + // covers every gate in one table. // 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 () => { - const host = new PluginRpcHost('p', new Set(['db:read:collections']), deps); - expect(ok(await host.dispatch(req('collections.listMine'), 42))).toBe(true); - const one = await host.dispatch(req('collections.get', { id: 1 }), 42); - expect(ok(one)).toBe(true); - expect(deps.getCollectionForUser).toHaveBeenCalledWith(42, 1); - expect((await host.dispatch(req('collections.listMine'), undefined)).ok).toBe(false); - }); - // weather.get, categories.list and rates.get moved to tests/unit/plugins/tenant-free.rpc.test.ts. // The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers. - 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); - expect(deps.markCountryVisited).toHaveBeenCalledWith(42, 'JP'); // uid host-bound, code normalized - expect(ok(await host.dispatch(req('atlas.markRegion', { regionCode: 'JP-13', countryCode: 'JP' }), 42))).toBe(true); - expect(ok(await host.dispatch(req('atlas.createBucketItem', { input: { name: 'Kyoto' } }), 42))).toBe(true); - expect((await host.dispatch(req('atlas.markCountry', { code: 'JP' }), undefined)).ok).toBe(false); // no user - expect((await host.dispatch(req('atlas.markCountry', { code: 'not-a-code-way-too-long' }), 42)).ok).toBe(false); - expect((await host.dispatch(req('atlas.createBucketItem', { input: { name: '' } }), 42)).ok).toBe(false); - }); - - it('vacay writes validate the date and are uid-bound', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:vacay']), deps); - expect(ok(await host.dispatch(req('vacay.toggleEntry', { date: '2026-08-01' }), 42))).toBe(true); - expect(deps.vacayToggleEntry).toHaveBeenCalledWith(42, '2026-08-01'); - expect(ok(await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2026-12-24', note: 'Xmas' }), 42))).toBe(true); - expect((await host.dispatch(req('vacay.toggleEntry', { date: 'tomorrow' }), 42)).ok).toBe(false); - expect((await host.dispatch(req('vacay.toggleEntry', { date: '2026-08-01' }), undefined)).ok).toBe(false); - }); - - it('journal writes require entry_date on create and a bound user', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:journal']), deps); - expect(ok(await host.dispatch(req('journal.createEntry', { journeyId: 3, input: { entry_date: '2026-08-01', story: 'x' } }), 42))).toBe(true); - expect(deps.createJournalEntry).toHaveBeenCalledWith(42, 3, expect.objectContaining({ entry_date: '2026-08-01' })); - expect((await host.dispatch(req('journal.createEntry', { journeyId: 3, input: { story: 'no date' } }), 42)).ok).toBe(false); - expect((await host.dispatch(req('journal.updateEntry', { entryId: 120, input: {} }), undefined)).ok).toBe(false); - expect(ok(await host.dispatch(req('journal.deleteEntry', { entryId: 120 }), 42))).toBe(true); - }); - - it('collections writes are schema-validated and uid-bound', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:collections']), deps); - expect(ok(await host.dispatch(req('collections.create', { input: { name: 'Tokyo eats' } }), 42))).toBe(true); - expect(deps.createCollectionForUser).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Tokyo eats' })); - expect(ok(await host.dispatch(req('collections.deletePlace', { placeId: 7 }), 42))).toBe(true); - expect((await host.dispatch(req('collections.create', { input: { name: 'x' } }), undefined)).ok).toBe(false); - const noGrant = new PluginRpcHost('p', new Set(['db:read:collections']), deps); - expect((await noGrant.dispatch(req('collections.create', { input: { name: 'x' } }), 42)).ok).toBe(false); - }); - // The files cases moved to tests/unit/files/files.rpc.test.ts with the handlers. - it('collab writes are gated by collab_edit and validate their inputs', async () => { - const host = new PluginRpcHost('p', new Set(['db:write:collab']), deps); - expect(ok(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: 'Ideas' } }), 42))).toBe(true); - expect((await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: '' } }), 42)).ok).toBe(false); - expect(ok(await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A', 'B'] } }), 42))).toBe(true); - expect((await host.dispatch(req('collab.createPoll', { tripId: 1, input: { question: 'Where?', options: ['A'] } }), 42)).ok).toBe(false); - expect(ok(await host.dispatch(req('collab.votePoll', { tripId: 1, pollId: 141, optionIndex: 0 }), 42))).toBe(true); - expect(ok(await host.dispatch(req('collab.createMessage', { tripId: 1, text: 'hi' }), 42))).toBe(true); - expect((await host.dispatch(req('collab.createMessage', { tripId: 1, text: '' }), 42)).ok).toBe(false); - expect(((await host.dispatch(req('collab.createNote', { tripId: 2, input: { title: 'x' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - 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);