mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory at 384, down from 944. These five are the risky group, because their addon gates lived inside the deps factory's closure rather than in the router. A handler that calls its service directly still works, just without the check, and neither the compiler nor the router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as one table and asserts two things for each: with the addon off it is refused with that addon's message, and the service is never reached. The table length is pinned, so a later method added without a row is a failure rather than a silent gap. One ordering detail worth knowing: on the atlas writes the router evaluated its params before the addon gate inside the closure, so a malformed country code still reports BAD_PARAMS rather than the addon. The handlers keep that order. The collections service reports refusals as status-tagged errors rather than typed ones, so mapCollectionError came across with the handlers: 403 and 404 become RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a HOST_ERROR instead of being dressed up as a permission problem.
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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();
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<T>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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. ---
|
||||
|
||||
@@ -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<string, unknown>, actingUserId: number): unknown;
|
||||
createCollabPoll(tripId: number, input: Record<string, unknown>, 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<string, unknown>): unknown;
|
||||
updateCollectionForUser(userId: number, id: number, input: Record<string, unknown>): unknown;
|
||||
saveCollectionPlace(userId: number, input: Record<string, unknown>): unknown;
|
||||
copyCollectionToTrip(userId: number, input: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): unknown;
|
||||
updateJournalEntry(userId: number, entryId: number, input: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>);
|
||||
});
|
||||
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<string, unknown>);
|
||||
});
|
||||
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<string, unknown>);
|
||||
});
|
||||
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<string, unknown>);
|
||||
});
|
||||
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.
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}): 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<string, unknown>;
|
||||
atlas?: Record<string, unknown>;
|
||||
vacay?: Record<string, unknown>;
|
||||
journey?: Record<string, unknown>;
|
||||
collections?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
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, unknown>, 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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}): 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, unknown>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
||||
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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
||||
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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
||||
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))
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user