From fa7992366354f397d19b697a49e3b915643ed698 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 13:45:30 +0200 Subject: [PATCH] feat(mcp): calendar feed tokens and the trip invite link export_trip_ics is a snapshot of one trip, so subscribing was out of reach and the all-trips feed had no counterpart at all. Both feed tokens can now be read, minted, rotated and revoked. The invite link is the one that grants membership, which create_share_link is not, and the descriptions say so in both directions so a model does not reach for the wrong one. The two consumer routes stay out: the ics URLs are what gets subscribed to, and accepting an invite is a human act. test-db's reset list was missing trip_invite_tokens, so a leftover invite re-attached itself to the next test's trip once ids restarted. --- server/src/nest/feeds/feeds.mcp.ts | 184 ++++++ server/src/nest/feeds/feeds.module.ts | 12 +- .../src/nest/trip-invite/trip-invite.mcp.ts | 137 +++++ .../nest/trip-invite/trip-invite.module.ts | 12 +- server/tests/helpers/test-db.ts | 1 + .../tests/unit/mcp/tools-feeds-invite.test.ts | 543 ++++++++++++++++++ 6 files changed, 885 insertions(+), 4 deletions(-) create mode 100644 server/src/nest/feeds/feeds.mcp.ts create mode 100644 server/src/nest/trip-invite/trip-invite.mcp.ts create mode 100644 server/tests/unit/mcp/tools-feeds-invite.test.ts diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts new file mode 100644 index 000000000..fedc59f24 --- /dev/null +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -0,0 +1,184 @@ +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from '../../nest-mcp'; +import { z } from 'zod'; +import { getAppUrl } from '../../app-config'; +import { DatabaseService } from '../database/database.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { isDemoUserId } from '../common/demo-write'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { FeedsService } from './feeds.service'; + +/** + * Calendar-feed MCP surface, mirroring the two authenticated token controllers + * in feeds.controller.ts. Not a port of a legacy registrar: the feeds domain + * never had MCP tools, so an assistant could produce a one-shot .ics snapshot + * (export_trip_ics) but could not switch on the live subscription the web UI + * offers, and the all-trips feed had no MCP counterpart at all. + * + * The two consumer routes (GET /api/feed/{trip,user}/:token.ics) stay out on + * purpose: they are the anonymous endpoints a calendar app polls, not something + * a model should call, and their only credential is the token in the URL. + * + * All eight ride `trips:share`. For the per-trip feed that follows the route, + * which requires share_manage on every verb including GET; the payload is the + * credential itself, and the credential is an anonymous read of the trip. The + * all-trips feed has no trip to hold a permission on (its routes are + * JwtAuthGuard only), but minting it publishes every trip the user can open + * behind one unauthenticated URL, so it takes the share scope rather than a + * read one. + */ +@McpController() +export class FeedsMcp { + constructor( + private readonly feeds: FeedsService, + private readonly db: DatabaseService, + private readonly env: RuntimeEnvService, + private readonly guards: McpToolGuardsService, + ) {} + + /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + private isDemoUser(userId: number): boolean { + return isDemoUserId(this.env, this.db, userId); + } + + /** + * The REST routes take the base URL from APP_URL and fall back to the calling + * request's own Host header. A tool call has no request to fall back to, so it + * uses the instance's canonical base-URL resolution (APP_URL, then the first + * allowed origin, then localhost) and always hands back an absolute, + * subscribable URL instead of a bare path. + */ + private base(): string { + return getAppUrl(); + } + + /** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */ + private denyTripFeed(tripId: number, userId: number) { + if (!this.db.canAccessTrip(tripId, userId)) return noAccess(); + if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); + return null; + } + + // ── One trip's feed ────────────────────────────────────────────────────── + + @Tool({ + name: 'get_trip_calendar_feed', + description: 'Get the subscribable calendar feed URL of one trip, or null when the feed is switched off. This is a live subscription a calendar app re-reads hourly, so it keeps up with the itinerary. Prefer export_trip_ics when the user wants a one-off .ics file to import once and be done.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'share' }, + }) + async getTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { + const denied = this.denyTripFeed(tripId, ctx.userId); + if (denied) return denied; + return ok(this.feeds.getTripToken(String(tripId), ctx.userId, this.base())); + } + + @Tool({ + name: 'enable_trip_calendar_feed', + description: 'Switch on the subscribable calendar feed of one trip and return its URL. Safe to repeat: a trip that already has a feed keeps the URL it has, so subscriptions somebody already set up survive. The URL carries a secret token and asks for no login, so whoever holds it can read the trip\'s dates.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'share' }, + }) + async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + const denied = this.denyTripFeed(tripId, ctx.userId); + if (denied) return denied; + return ok(this.feeds.generateTripToken(String(tripId), ctx.userId, this.base())); + } + + @Tool({ + name: 'rotate_trip_calendar_feed', + description: 'Issue a fresh URL for one trip\'s calendar feed. Every calendar subscribed to the old URL silently stops updating, so use this when the old link leaked or the user asked to cut it off, not to switch the feed on: enable_trip_calendar_feed does that without breaking anything.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'share' }, + }) + async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + const denied = this.denyTripFeed(tripId, ctx.userId); + if (denied) return denied; + return ok(this.feeds.rotateTripToken(String(tripId), ctx.userId, this.base())); + } + + @Tool({ + name: 'disable_trip_calendar_feed', + description: 'Switch off one trip\'s calendar feed. The URL stops resolving and every subscription to it breaks. Calling enable_trip_calendar_feed afterwards hands out a different URL, so everybody has to subscribe again.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'trips', mode: 'share' }, + }) + async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + const denied = this.denyTripFeed(tripId, ctx.userId); + if (denied) return denied; + this.feeds.disableTripToken(String(tripId), ctx.userId); + // Matches the route, which answers the cleared token as a null URL. + return ok({ feed_url: null }); + } + + // ── The all-trips feed ─────────────────────────────────────────────────── + // Per user, not per trip: one URL covering every unarchived trip the user + // owns or is a member of. No trip id, so no trip permission to check either. + + @Tool({ + name: 'get_all_trips_calendar_feed', + description: 'Get the subscribable calendar feed URL covering every trip the user can open, or null when it is switched off. One feed for the whole travel calendar; get_trip_calendar_feed is the per-trip one.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'share' }, + }) + async getAllTripsCalendarFeed(_input: Record, ctx: McpContext) { + return ok(this.feeds.getUserToken(ctx.userId, this.base())); + } + + @Tool({ + name: 'enable_all_trips_calendar_feed', + description: 'Switch on the calendar feed covering every trip the user can open and return its URL. Safe to repeat: an existing feed keeps its URL. The URL needs no login and follows the user\'s trips as they change, so a trip added later shows up in it without anyone re-subscribing.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'share' }, + }) + async enableAllTripsCalendarFeed(_input: Record, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + return ok(this.feeds.generateUserToken(ctx.userId, this.base())); + } + + @Tool({ + name: 'rotate_all_trips_calendar_feed', + description: 'Issue a fresh URL for the all-trips calendar feed. Every calendar subscribed to the old URL stops updating. Use it when that link leaked; enable_all_trips_calendar_feed is the way to switch the feed on without breaking existing subscriptions.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'share' }, + }) + async rotateAllTripsCalendarFeed(_input: Record, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + return ok(this.feeds.rotateUserToken(ctx.userId, this.base())); + } + + @Tool({ + name: 'disable_all_trips_calendar_feed', + description: 'Switch off the all-trips calendar feed. The URL stops resolving and every subscription to it breaks. Per-trip feeds are unaffected: disable_trip_calendar_feed switches those off one at a time.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'trips', mode: 'share' }, + }) + async disableAllTripsCalendarFeed(_input: Record, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + this.feeds.disableUserToken(ctx.userId); + return ok({ feed_url: null }); + } +} diff --git a/server/src/nest/feeds/feeds.module.ts b/server/src/nest/feeds/feeds.module.ts index 1e699bdd6..b50b01d61 100644 --- a/server/src/nest/feeds/feeds.module.ts +++ b/server/src/nest/feeds/feeds.module.ts @@ -1,16 +1,24 @@ import { Module } from '@nestjs/common'; +import { AppConfigModule } from '../app-config/app-config.module'; import { CalendarModule } from '../calendar/calendar.module'; import { DatabaseModule } from '../database/database.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { RealtimeModule } from '../realtime/realtime.module'; import { FeedsService } from './feeds.service'; +import { FeedsMcp } from './feeds.mcp'; import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller'; @Module({ // Calendars, not the trip aggregate: feeds only ever needed an ICS string, and // importing TripsModule for it pulled budget, packing, places and the rest in. // Permissions comes in for TripAccessGuard, which gates the trip feed token. - imports: [CalendarModule, DatabaseModule, PermissionsModule], + // The last three are FeedsMcp's: McpSharedModule for the RBAC check the route + // gets from its guard, AppConfigModule and RealtimeModule because a module + // graph assembled without AppModule (the e2e harnesses) has to instantiate + // those two @Global modules itself before anything can inject out of them. + imports: [CalendarModule, DatabaseModule, PermissionsModule, McpSharedModule, AppConfigModule, RealtimeModule], controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController], - providers: [FeedsService], + providers: [FeedsService, FeedsMcp], }) export class FeedsModule {} diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts new file mode 100644 index 000000000..ab3c6f9b6 --- /dev/null +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -0,0 +1,137 @@ +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from '../../nest-mcp'; +import { z } from 'zod'; +import { getAppUrl } from '../../app-config'; +import { AuditService } from '../audit/audit.service'; +import { DatabaseService } from '../database/database.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { isDemoUserId } from '../common/demo-write'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; +import { canShareTrips, canWrite } from '../../mcp/scopes'; +import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; +import { tripInviteLinkCreateRequestSchema } from '@trek/shared'; +import type { TripInviteLinkCreateRequest } from '@trek/shared'; +import { TripInviteService, type TripInviteInfo } from './trip-invite.service'; + +/** + * Invite-link MCP surface, mirroring TripInviteLinkController. The join half of + * the domain (GET /api/trip-invites/:token and its accept route) stays out: + * following an invite is a person deciding to join somebody else's trip, and + * putting the accept route on a tool would let an assistant walk its user into + * a trip on the strength of a token it found somewhere. + * + * Both scopes, not either: the invite link is managed like the public share + * link (share_manage on every verb, GET included, because the payload is the + * credential) but it hands out trip MEMBERSHIP, which is what `trips:write` + * covers. Requiring both keeps a share-scoped token from adding members and a + * write-scoped token from minting an anonymous link, and it can only ever be + * narrower than the route. + */ +@McpController() +export class TripInviteMcp { + constructor( + private readonly invites: TripInviteService, + private readonly db: DatabaseService, + private readonly env: RuntimeEnvService, + private readonly guards: McpToolGuardsService, + private readonly audit: AuditService, + ) {} + + /** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */ + private isDemoUser(userId: number): boolean { + return isDemoUserId(this.env, this.db, userId); + } + + /** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */ + private denyManage(tripId: number, userId: number) { + if (!this.invites.verifyTripAccess(String(tripId), userId)) return noAccess(); + if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); + return null; + } + + /** The link the client builds around the raw token (client/src/App.tsx: /join/:token). */ + private describe(info: TripInviteInfo) { + return { ...info, url: `${getAppUrl()}/join/${info.token}` }; + } + + @Tool({ + name: 'get_trip_invite_link', + description: 'Get the trip\'s current invite link, the one that adds whoever opens it to the trip as a member. Returns null when the trip has none. This is not the read-only public view link: get_share_link is that one.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), + }) + async getTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { + const denied = this.denyManage(tripId, ctx.userId); + if (denied) return denied; + const info = this.invites.get(tripId); + return ok({ invite_link: info ? this.describe(info) : null }); + } + + @Tool({ + name: 'create_trip_invite_link', + description: 'Create the trip\'s invite link, or rotate it: a trip has exactly one, so calling this again issues a fresh token and the previous link stops working immediately. Anyone with a TREK account who opens the link and signs in becomes a member of the trip, which makes it a different and far stronger thing than create_share_link, whose link only shows a read-only public view. Use add_trip_member instead when the person already has an account and is known by name or email.', + inputSchema: { + tripId: z.number().int().positive(), + expires_in_days: tripInviteLinkCreateRequestSchema.shape.expires_in_days + .describe('Days until the link stops working; omit, null or 0 leaves it valid until it is rotated or deleted'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), + }) + async createTripInviteLink( + { tripId, expires_in_days }: { tripId: number } & TripInviteLinkCreateRequest, + ctx: McpContext, + ) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + const denied = this.denyManage(tripId, ctx.userId); + if (denied) return denied; + // The route's own coercion, kept verbatim: the shared contract admits a + // digits-only string as well as a number, and anything that does not parse + // to a finite value means no expiry rather than an error. + const parsed = expires_in_days != null && String(expires_in_days).trim() !== '' + ? Number.parseInt(String(expires_in_days)) + : null; + const days = Number.isFinite(parsed as number) ? parsed : null; + const info = this.invites.createOrRotate(tripId, ctx.userId, days); + // Minting a membership credential is audited wherever it happens, so an + // admin reading the log sees the same row for a link made through an + // assistant as for one made in the planner. No request here, hence no ip. + this.audit.writeAudit({ + userId: ctx.userId, + action: 'trip.invite_link_create', + resource: String(tripId), + ip: null, + details: { expires_in_days: days }, + }); + return ok({ invite_link: this.describe(info) }); + } + + @Tool({ + name: 'delete_trip_invite_link', + description: 'Revoke the trip\'s invite link. The URL stops working at once, so nobody else can join through it. Members who already joined stay on the trip: remove_trip_member is what takes somebody off it.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), + }) + async deleteTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) { + if (this.isDemoUser(ctx.userId)) return demoDenied(); + const denied = this.denyManage(tripId, ctx.userId); + if (denied) return denied; + this.invites.remove(tripId); + this.audit.writeAudit({ + userId: ctx.userId, + action: 'trip.invite_link_delete', + resource: String(tripId), + ip: null, + }); + return ok({ success: true }); + } +} diff --git a/server/src/nest/trip-invite/trip-invite.module.ts b/server/src/nest/trip-invite/trip-invite.module.ts index 85ba77804..a44391a5b 100644 --- a/server/src/nest/trip-invite/trip-invite.module.ts +++ b/server/src/nest/trip-invite/trip-invite.module.ts @@ -2,13 +2,21 @@ import { RateLimitModule } from '../common/rate-limit.module'; import { Module } from '@nestjs/common'; import { TripInviteLinkController, TripInviteController } from './trip-invite.controller'; import { TripInviteService } from './trip-invite.service'; +import { TripInviteMcp } from './trip-invite.mcp'; +import { AppConfigModule } from '../app-config/app-config.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; +import { RealtimeModule } from '../realtime/realtime.module'; import { AuditModule } from '../audit/audit.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; @Module({ - imports: [RateLimitModule, PermissionsModule, AuditModule, TripMembershipModule], + // The last three carry TripInviteMcp: McpSharedModule for the RBAC check the + // controller does inline, and the two @Global modules it injects out of, + // which a graph assembled without AppModule (the e2e harness) must + // instantiate itself. + imports: [RateLimitModule, PermissionsModule, AuditModule, TripMembershipModule, McpSharedModule, AppConfigModule, RealtimeModule], controllers: [TripInviteLinkController, TripInviteController], - providers: [TripInviteService], + providers: [TripInviteService, TripInviteMcp], }) export class TripInviteModule {} diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index cd283ed10..31253c46b 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -68,6 +68,7 @@ const RESET_TABLES = [ 'days', // Trip 'share_tokens', + 'trip_invite_tokens', 'trip_members', 'trips', // Journey diff --git a/server/tests/unit/mcp/tools-feeds-invite.test.ts b/server/tests/unit/mcp/tools-feeds-invite.test.ts new file mode 100644 index 000000000..e2c9521f9 --- /dev/null +++ b/server/tests/unit/mcp/tools-feeds-invite.test.ts @@ -0,0 +1,543 @@ +/** + * Unit tests for the two link-management MCP surfaces that had no tools at all + * before: FeedsMcp (the subscribable ICS calendar feed, per trip and for all + * trips) and TripInviteMcp (the invite link that grants trip membership). + * + * Both mirror routes whose payload IS the credential, so the gates are the + * interesting part: trip access before permission, share_manage on reads as + * well as writes, and demo mode refused on every write. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: number | string, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, addTripMember } from '../../helpers/factories'; +import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +const BASE = 'https://trek.example'; + +type ToolResult = Awaited>; + +/** Typed view on a successful tool payload. */ +function payload(result: ToolResult): T { + return parseToolResult(result) as T; +} + +/** The raw text of a result, for the error sentences that are not JSON. */ +function textOf(result: ToolResult): string { + const { content } = result as { content: { type: string; text?: string }[] }; + return content.find((c) => c.type === 'text')?.text ?? ''; +} + +function tripToken(tripId: number): string | null { + const row = testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(tripId) as { feed_token: string | null } | undefined; + return row?.feed_token ?? null; +} + +function userToken(userId: number): string | null { + const row = testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as { feed_token: string | null } | undefined; + return row?.feed_token ?? null; +} + +function inviteRow(tripId: number) { + return testDb.prepare('SELECT token, expires_at, created_by FROM trip_invite_tokens WHERE trip_id = ?').get(tripId) as + | { token: string; expires_at: string | null; created_by: number } + | undefined; +} + +interface FeedResult { feed_url: string | null } +interface InviteLink { token: string; expires_at: string | null; created_at: string; url: string } +interface InviteResult { invite_link: InviteLink | null } + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + // trip_invite_tokens is not in the shared helper's RESET_TABLES, and trip ids + // restart after a DELETE-based reset, so a leftover row would attach itself + // to the next test's trip. + testDb.exec('DELETE FROM trip_invite_tokens'); + // The permission levels are cached in module state that resetTestDb knows + // nothing about, so the one test that lowers share_manage would otherwise + // keep the lowered level alive for every test after it. + invalidatePermissionsCache(); + delete process.env.DEMO_MODE; + process.env.APP_URL = BASE; +}); + +afterAll(() => { + delete process.env.APP_URL; + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// One trip's calendar feed +// --------------------------------------------------------------------------- + +describe('Tool: get_trip_calendar_feed', () => { + it('answers null while the feed is off and the subscribable URL once it is on', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const off = payload(await h.client.callTool({ name: 'get_trip_calendar_feed', arguments: { tripId: trip.id } })); + expect(off.feed_url).toBeNull(); + + await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); + const on = payload(await h.client.callTool({ name: 'get_trip_calendar_feed', arguments: { tripId: trip.id } })); + expect(on.feed_url).toBe(`${BASE}/api/feed/trip/${tripToken(trip.id)}.ics`); + }); + }); + + it('reads the feed of a trip the user is a member of once share_manage is lowered to trip_member', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_share_manage', 'trip_member')").run(); + await withHarness(owner.id, async (h) => { + await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); + }); + await withHarness(member.id, async (h) => { + const result = payload(await h.client.callTool({ name: 'get_trip_calendar_feed', arguments: { tripId: trip.id } })); + expect(result.feed_url).toBe(`${BASE}/api/feed/trip/${tripToken(trip.id)}.ics`); + }); + }); +}); + +describe('Tool: enable_trip_calendar_feed', () => { + it('mints one token and hands back the same URL on a second call', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const first = payload(await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } })); + const minted = tripToken(trip.id); + expect(minted).toBeTruthy(); + expect(first.feed_url).toBe(`${BASE}/api/feed/trip/${minted}.ics`); + + const second = payload(await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } })); + expect(second.feed_url).toBe(first.feed_url); + expect(tripToken(trip.id)).toBe(minted); + }); + }); +}); + +describe('Tool: rotate_trip_calendar_feed', () => { + it('replaces the token so the old URL stops resolving', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); + const before = tripToken(trip.id); + const rotated = payload(await h.client.callTool({ name: 'rotate_trip_calendar_feed', arguments: { tripId: trip.id } })); + const after = tripToken(trip.id); + expect(after).not.toBe(before); + expect(rotated.feed_url).toBe(`${BASE}/api/feed/trip/${after}.ics`); + }); + }); +}); + +describe('Tool: disable_trip_calendar_feed', () => { + it('clears the token and answers a null URL', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); + const result = payload(await h.client.callTool({ name: 'disable_trip_calendar_feed', arguments: { tripId: trip.id } })); + expect(result.feed_url).toBeNull(); + expect(tripToken(trip.id)).toBeNull(); + }); + }); +}); + +describe('trip calendar feed gates', () => { + const TRIP_FEED_TOOLS = [ + 'get_trip_calendar_feed', + 'enable_trip_calendar_feed', + 'rotate_trip_calendar_feed', + 'disable_trip_calendar_feed', + ]; + + it.each(TRIP_FEED_TOOLS)('%s refuses a trip the user cannot reach', async (name) => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const foreign = createTrip(testDb, other.id); + testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('foreign-token', foreign.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: foreign.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Trip not found or access denied.'); + // The service scopes its own UPDATE, but the tool must refuse before it: + // generate/rotate answer a URL for a token they never wrote. + expect(tripToken(foreign.id)).toBe('foreign-token'); + }); + }); + + it.each(TRIP_FEED_TOOLS)('%s refuses a trip id that does not exist', async (name) => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: 987654 } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Trip not found or access denied.'); + }); + }); + + it.each(TRIP_FEED_TOOLS)('%s refuses a member without share_manage', async (name) => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('owner-token', trip.id); + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('You do not have permission to perform this action on this trip.'); + expect(tripToken(trip.id)).toBe('owner-token'); + }); + }); + + it.each(['enable_trip_calendar_feed', 'rotate_trip_calendar_feed', 'disable_trip_calendar_feed'])( + '%s refuses a demo account', + async (name) => { + process.env.DEMO_MODE = 'true'; + const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); + const trip = createTrip(testDb, demo.id); + testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('demo-token', trip.id); + await withHarness(demo.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); + expect(tripToken(trip.id)).toBe('demo-token'); + }); + }, + ); +}); + +// --------------------------------------------------------------------------- +// The all-trips calendar feed +// --------------------------------------------------------------------------- + +describe('Tool: get_all_trips_calendar_feed', () => { + it('answers null while the feed is off and the user URL once it is on', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const off = payload(await h.client.callTool({ name: 'get_all_trips_calendar_feed', arguments: {} })); + expect(off.feed_url).toBeNull(); + + await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); + const on = payload(await h.client.callTool({ name: 'get_all_trips_calendar_feed', arguments: {} })); + expect(on.feed_url).toBe(`${BASE}/api/feed/user/${userToken(user.id)}.ics`); + }); + }); +}); + +describe('Tool: enable_all_trips_calendar_feed', () => { + it('mints the calling user their own token and leaves everybody else alone', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + await withHarness(user.id, async (h) => { + const first = payload(await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} })); + const minted = userToken(user.id); + expect(first.feed_url).toBe(`${BASE}/api/feed/user/${minted}.ics`); + expect(userToken(other.id)).toBeNull(); + + const second = payload(await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} })); + expect(second.feed_url).toBe(first.feed_url); + expect(userToken(user.id)).toBe(minted); + }); + }); +}); + +describe('Tool: rotate_all_trips_calendar_feed', () => { + it('replaces the token so the old URL stops resolving', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); + const before = userToken(user.id); + const rotated = payload(await h.client.callTool({ name: 'rotate_all_trips_calendar_feed', arguments: {} })); + const after = userToken(user.id); + expect(after).not.toBe(before); + expect(rotated.feed_url).toBe(`${BASE}/api/feed/user/${after}.ics`); + }); + }); +}); + +describe('Tool: disable_all_trips_calendar_feed', () => { + it('clears the token and answers a null URL', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); + const result = payload(await h.client.callTool({ name: 'disable_all_trips_calendar_feed', arguments: {} })); + expect(result.feed_url).toBeNull(); + expect(userToken(user.id)).toBeNull(); + }); + }); +}); + +describe('all-trips calendar feed gates', () => { + it.each(['enable_all_trips_calendar_feed', 'rotate_all_trips_calendar_feed', 'disable_all_trips_calendar_feed'])( + '%s refuses a demo account', + async (name) => { + process.env.DEMO_MODE = 'true'; + const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); + testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('demo-user-token', demo.id); + await withHarness(demo.id, async (h) => { + const result = await h.client.callTool({ name, arguments: {} }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); + expect(userToken(demo.id)).toBe('demo-user-token'); + }); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Trip invite link +// --------------------------------------------------------------------------- + +describe('Tool: get_trip_invite_link', () => { + it('answers null without a link, then the token and the join URL', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const none = payload(await h.client.callTool({ name: 'get_trip_invite_link', arguments: { tripId: trip.id } })); + expect(none.invite_link).toBeNull(); + + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); + const link = payload(await h.client.callTool({ name: 'get_trip_invite_link', arguments: { tripId: trip.id } })); + expect(link.invite_link?.token).toBe(inviteRow(trip.id)?.token); + expect(link.invite_link?.url).toBe(`${BASE}/join/${inviteRow(trip.id)?.token}`); + expect(link.invite_link?.expires_at).toBeNull(); + }); + }); +}); + +describe('Tool: create_trip_invite_link', () => { + it('stores the token against the trip and audits who minted it', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const created = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); + const row = inviteRow(trip.id); + expect(row?.token).toBe(created.invite_link?.token); + expect(row?.created_by).toBe(user.id); + expect(row?.expires_at).toBeNull(); + + const audit = testDb.prepare("SELECT user_id, resource, details FROM audit_log WHERE action = 'trip.invite_link_create'").get() as + | { user_id: number; resource: string; details: string | null } + | undefined; + expect(audit?.user_id).toBe(user.id); + expect(audit?.resource).toBe(String(trip.id)); + }); + }); + + it('takes an expiry as a number or as the digits-only string the contract allows', async () => { + const { user } = createUser(testDb); + const numeric = createTrip(testDb, user.id); + const stringy = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: numeric.id, expires_in_days: 7 } }); + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: stringy.id, expires_in_days: '7' } }); + }); + const expected = Date.now() + 7 * 86400000; + for (const trip of [numeric, stringy]) { + const at = inviteRow(trip.id)?.expires_at; + expect(at).toBeTruthy(); + expect(Math.abs(new Date(at as string).getTime() - expected)).toBeLessThan(60_000); + } + }); + + it('reads a non-positive or empty expiry as no expiry at all', async () => { + const { user } = createUser(testDb); + const zero = createTrip(testDb, user.id); + const blank = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: zero.id, expires_in_days: 0 } }); + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: blank.id, expires_in_days: '' } }); + }); + expect(inviteRow(zero.id)?.expires_at).toBeNull(); + expect(inviteRow(blank.id)?.expires_at).toBeNull(); + }); + + it('rejects an expiry the shared contract does not admit', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id, expires_in_days: '7 days' } }); + expect(result.isError).toBe(true); + expect(inviteRow(trip.id)).toBeUndefined(); + }); + }); + + it('rotates in place: a second call replaces the token and keeps one row', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const first = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); + const second = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); + expect(second.invite_link?.token).not.toBe(first.invite_link?.token); + const count = testDb.prepare('SELECT COUNT(*) AS n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; + expect(count.n).toBe(1); + expect(inviteRow(trip.id)?.token).toBe(second.invite_link?.token); + }); + }); +}); + +describe('Tool: delete_trip_invite_link', () => { + it('removes the link and audits the revocation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); + const result = payload<{ success: boolean }>(await h.client.callTool({ name: 'delete_trip_invite_link', arguments: { tripId: trip.id } })); + expect(result.success).toBe(true); + expect(inviteRow(trip.id)).toBeUndefined(); + const audit = testDb.prepare("SELECT resource FROM audit_log WHERE action = 'trip.invite_link_delete'").get() as { resource: string } | undefined; + expect(audit?.resource).toBe(String(trip.id)); + }); + }); +}); + +describe('trip invite link gates', () => { + const INVITE_TOOLS = ['get_trip_invite_link', 'create_trip_invite_link', 'delete_trip_invite_link']; + + it.each(INVITE_TOOLS)('%s refuses a trip the user cannot reach', async (name) => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const foreign = createTrip(testDb, other.id); + await withHarness(other.id, async (h) => { + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: foreign.id } }); + }); + const existing = inviteRow(foreign.id)?.token; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: foreign.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Trip not found or access denied.'); + }); + expect(inviteRow(foreign.id)?.token).toBe(existing); + }); + + it.each(INVITE_TOOLS)('%s refuses a trip id that does not exist', async (name) => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: 987654 } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Trip not found or access denied.'); + }); + }); + + it.each(INVITE_TOOLS)('%s refuses a member without share_manage', async (name) => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + await withHarness(owner.id, async (h) => { + await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); + }); + const existing = inviteRow(trip.id)?.token; + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('You do not have permission to perform this action on this trip.'); + }); + expect(inviteRow(trip.id)?.token).toBe(existing); + }); + + it.each(['create_trip_invite_link', 'delete_trip_invite_link'])('%s refuses a demo account', async (name) => { + process.env.DEMO_MODE = 'true'; + const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); + const trip = createTrip(testDb, demo.id); + testDb.prepare('INSERT INTO trip_invite_tokens (trip_id, token, created_by) VALUES (?, ?, ?)').run(trip.id, 'seeded-token', demo.id); + await withHarness(demo.id, async (h) => { + const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); + }); + expect(inviteRow(trip.id)?.token).toBe('seeded-token'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope gating: the feed tools ride trips:share, the invite tools need +// trips:share AND trips:write because the link grants membership. +// --------------------------------------------------------------------------- + +describe('scope gating', () => { + const FEED_TOOLS = [ + 'get_trip_calendar_feed', 'enable_trip_calendar_feed', 'rotate_trip_calendar_feed', 'disable_trip_calendar_feed', + 'get_all_trips_calendar_feed', 'enable_all_trips_calendar_feed', 'rotate_all_trips_calendar_feed', 'disable_all_trips_calendar_feed', + ]; + const INVITE_TOOLS = ['get_trip_invite_link', 'create_trip_invite_link', 'delete_trip_invite_link']; + + async function toolNames(scopes: string[] | null): Promise { + const h = await createMcpHarness({ userId: 1, withResources: false, scopes }); + try { + const { tools } = await h.client.listTools(); + return tools.map((t) => t.name); + } finally { + await h.cleanup(); + } + } + + it('a static token (null scopes) sees all eleven', async () => { + const names = await toolNames(null); + for (const tool of [...FEED_TOOLS, ...INVITE_TOOLS]) expect(names).toContain(tool); + }); + + it('trips:read alone sees none of them', async () => { + const names = await toolNames(['trips:read']); + for (const tool of [...FEED_TOOLS, ...INVITE_TOOLS]) expect(names).not.toContain(tool); + }); + + it('trips:share carries the feed tools but not the membership-granting invite link', async () => { + const names = await toolNames(['trips:share']); + for (const tool of FEED_TOOLS) expect(names).toContain(tool); + for (const tool of INVITE_TOOLS) expect(names).not.toContain(tool); + }); + + it('trips:write alone carries neither', async () => { + const names = await toolNames(['trips:write']); + for (const tool of [...FEED_TOOLS, ...INVITE_TOOLS]) expect(names).not.toContain(tool); + }); + + it('trips:share plus trips:write unlocks the invite link', async () => { + const names = await toolNames(['trips:share', 'trips:write']); + for (const tool of INVITE_TOOLS) expect(names).toContain(tool); + }); +});