From fd6381c814a92f902c750e04c4156daabc05eb1b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 01:03:02 +0200 Subject: [PATCH] feat(api): add a versioned read-only public API for integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TREK's REST surface is internal: it answers a session cookie, carries no version, and changes with the client. That is fine for our own frontend and useless for anyone building against us — the Dawarich integration people have been asking for since April needs something it can rely on not moving. So this adds a small one rather than opening the existing 549 endpoints: GET /api/v1/trips the trips the caller can reach GET /api/v1/trips/:id one trip, ?include= picks the sections Auth reuses the trek_ tokens users already manage under Settings, via Bearer or X-API-Key. No new credential type, no new table — the guard calls the same hash lookup the MCP transport does. It deliberately refuses a session JWT and, for now, OAuth bearers: a credential whose scopes this surface does not yet interpret is worse than one it rejects outright. Access is decided per trip against canAccessTrip, the same owner-or-member predicate as everywhere else, and a trip the caller may not read answers 404 identically to one that does not exist — a 403 would confirm the id is real and turn the endpoint into a way to count someone else's trips. The payload is not the rows. Ids, foreign keys and order_index stay inside; accommodation day ids resolve to ISO dates, places arrive in planned order, and every dated thing carries its date so a consumer can join on days rather than on ids it has no way to look up. --- server/src/nest/app.module.ts | 3 +- .../src/nest/common/validate-route-guards.ts | 3 +- server/src/nest/public-api/api-token.guard.ts | 73 +++++ .../nest/public-api/public-api.controller.ts | 108 ++++++ .../src/nest/public-api/public-api.module.ts | 21 ++ .../src/nest/public-api/public-api.service.ts | 307 ++++++++++++++++++ server/tests/e2e/public-api.e2e.test.ts | 273 ++++++++++++++++ .../unit/nest/public-api.controller.test.ts | 128 ++++++++ .../tests/unit/nest/public-api.guard.test.ts | 109 +++++++ shared/src/index.ts | 1 + shared/src/public-api/public-api.schema.ts | 139 ++++++++ 11 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 server/src/nest/public-api/api-token.guard.ts create mode 100644 server/src/nest/public-api/public-api.controller.ts create mode 100644 server/src/nest/public-api/public-api.module.ts create mode 100644 server/src/nest/public-api/public-api.service.ts create mode 100644 server/tests/e2e/public-api.e2e.test.ts create mode 100644 server/tests/unit/nest/public-api.controller.test.ts create mode 100644 server/tests/unit/nest/public-api.guard.test.ts create mode 100644 shared/src/public-api/public-api.schema.ts diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 04113f9d3..2653b8a10 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -11,6 +11,7 @@ import { GlobalAuthGuard } from './auth/global-auth.guard'; import { MfaPolicyGuard } from './auth/mfa-policy.guard'; import { ManagedGuard } from './common/managed.guard'; import { WeatherModule } from './weather/weather.module'; +import { PublicApiModule } from './public-api/public-api.module'; import { HelpModule } from './help/help.module'; import { AirportsModule } from './airports/airports.module'; import { ConfigModule } from './config/config.module'; @@ -75,7 +76,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index e566e2014..2937c2b17 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -5,6 +5,7 @@ import { IS_PUBLIC, OPTIONAL_AUTH } from '../auth/public.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CookieAuthGuard } from '../auth/cookie-auth.guard'; import { OptionalJwtGuard } from '../auth/optional-jwt.guard'; +import { ApiTokenGuard } from '../public-api/api-token.guard'; export interface RouteGuardEntry { /** `ControllerClass.methodName` */ @@ -21,7 +22,7 @@ export interface RouteGuardEntry { * no req.user, but they never resolve one, so they only ever appear behind one * of these three. */ -const AUTHENTICATING_GUARDS: unknown[] = [JwtAuthGuard, CookieAuthGuard, OptionalJwtGuard]; +const AUTHENTICATING_GUARDS: unknown[] = [JwtAuthGuard, CookieAuthGuard, OptionalJwtGuard, ApiTokenGuard]; /** * Boot-time gate: every registered route must be authenticated, or say why not. diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts new file mode 100644 index 000000000..a413ce0a8 --- /dev/null +++ b/server/src/nest/public-api/api-token.guard.ts @@ -0,0 +1,73 @@ +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; +import type { Request } from 'express'; +import { TokenService } from '../tokens/token.service'; + +/** + * Authenticates a caller of the public API with a long-lived `trek_…` token. + * + * The tokens are the ones users already manage under Settings → Integrations; this + * guard adds no new credential type and no new table. It reuses + * `TokenService.verifyMcpToken`, which hashes the presented token with SHA-256 and + * looks up the hash — the raw token is never stored and never compared in the + * application, so a database read cannot yield a usable credential. + * + * Deliberately narrower than the MCP transport's `verifyToken`, which also accepts + * OAuth bearer tokens and a plain web-session JWT: + * + * - **No session JWT.** A session cookie leaking into a third-party integration is + * exactly what a machine credential exists to prevent, and a JWT that reaches + * this surface is almost always an accident. + * - **No OAuth tokens (yet).** They carry scopes this surface does not interpret + * yet, and accepting a credential whose restrictions you ignore is worse than + * refusing it. When `/api/v1` grows write routes, scopes get honoured first. + * + * The guard authenticates but does not authorise: it resolves `req.user` and stops + * there. Which trips that user may read is decided per row against + * `DatabaseService.canAccessTrip`, never from anything the caller sent. + */ +@Injectable() +export class ApiTokenGuard implements CanActivate { + constructor(private readonly tokens: TokenService) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const token = extractApiToken(req); + if (!token) { + throw new HttpException( + { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + 401, + ); + } + const user = this.tokens.verifyMcpToken(token); + if (!user) { + throw new HttpException( + { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, + 401, + ); + } + req.user = user; + return true; + } +} + +/** + * `Authorization: Bearer trek_…` or `X-API-Key: trek_…`. + * + * Both spellings exist because integrators expect one or the other and neither is + * wrong; the header is the only difference. Anything that is not a `trek_` token is + * rejected here rather than passed to the lookup — a session JWT would otherwise + * travel one step further into the system than it should, and the hash of an + * arbitrary string is a pointless query. + */ +function extractApiToken(req: Request): string | null { + const header = req.headers['authorization']; + if (typeof header === 'string' && header.startsWith('Bearer ')) { + const candidate = header.slice(7).trim(); + if (candidate.startsWith('trek_')) return candidate; + } + const apiKey = req.headers['x-api-key']; + if (typeof apiKey === 'string' && apiKey.trim().startsWith('trek_')) { + return apiKey.trim(); + } + return null; +} diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts new file mode 100644 index 000000000..8f670c4cf --- /dev/null +++ b/server/src/nest/public-api/public-api.controller.ts @@ -0,0 +1,108 @@ +import { Controller, Get, HttpException, Param, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { + PUBLIC_API_INCLUDES, + publicApiIncludeQuerySchema, + type PublicApiInclude, + type PublicApiTrip, + type PublicApiTripList, +} from '@trek/shared'; +import { ApiTokenGuard } from './api-token.guard'; +import { PublicApiService } from './public-api.service'; + +/** + * `/api/v1` — the versioned, read-only surface for third-party integrations. + * + * Everything else under `/api` is internal: it answers a session cookie, carries no + * version, and is free to change with the client. This prefix is the opposite + * promise, and the version in the path is what makes that promise keepable — a + * breaking change ships as `/api/v2` rather than as an edit here. + * + * The surface is deliberately small. Two read endpoints are enough for the + * integrations asking for one (a location tracker enriching its own trips), and a + * small contract is one that can actually be honoured. Write access is not a + * missing feature: it would need scope enforcement this surface does not implement + * yet, and a token that reads everything is a very different thing from one that + * can also delete a trip. + */ +@Controller('api/v1') +@UseGuards(ApiTokenGuard) +export class PublicApiController { + constructor(private readonly api: PublicApiService) {} + + /** Every trip the token's owner can reach, without itineraries. */ + @Get('trips') + listTrips(@Req() req: Request): PublicApiTripList { + return { trips: this.api.listTrips(requireUserId(req)) }; + } + + /** + * One trip, with the sections named in `?include=`. + * + * A trip the caller may not read answers 404, identical to one that does not + * exist. That is the point: a 403 would confirm the id is real, which turns the + * endpoint into a way to count someone else's trips. + */ + @Get('trips/:id') + getTrip( + @Req() req: Request, + @Param('id') id: string, + @Query('include') include?: string, + ): PublicApiTrip { + const tripId = parseTripId(id); + const trip = this.api.getTrip(tripId, requireUserId(req), parseInclude(include)); + if (!trip) { + throw new HttpException({ error: 'Trip not found' }, 404); + } + return trip; + } +} + +/** + * The guard has already resolved the user; this is the type narrowing plus a + * belt-and-braces check. If it ever throws, a route was mounted without the guard — + * a 401 is then the right answer, and a loud one. + */ +function requireUserId(req: Request): number { + const id = req.user?.id; + if (typeof id !== 'number') { + throw new HttpException({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }, 401); + } + return id; +} + +/** + * Trip ids are integers. Anything else is rejected before it reaches a query — + * `parseInt` would happily turn "12abc" into 12, and a route that quietly accepts + * a mangled id is a route whose logs stop matching reality. + */ +function parseTripId(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new HttpException({ error: 'Invalid trip id' }, 400); + } + const id = Number(raw); + if (!Number.isSafeInteger(id) || id <= 0) { + throw new HttpException({ error: 'Invalid trip id' }, 400); + } + return id; +} + +/** + * `?include=days,notes`. Absent means everything, because a full sync is the common + * case and a caller that wants less says so. + * + * An unknown section is a 400 rather than being ignored: silently dropping a typo + * would hand the caller a payload that is missing exactly what they asked for, and + * they would debug their own code for it. + */ +function parseInclude(raw?: string): PublicApiInclude[] { + if (raw === undefined || raw.trim() === '') return [...PUBLIC_API_INCLUDES]; + const parsed = publicApiIncludeQuerySchema.safeParse(raw); + if (!parsed.success) { + throw new HttpException( + { error: `include must be a comma-separated list of: ${PUBLIC_API_INCLUDES.join(', ')}` }, + 400, + ); + } + return parsed.data; +} diff --git a/server/src/nest/public-api/public-api.module.ts b/server/src/nest/public-api/public-api.module.ts new file mode 100644 index 000000000..e6fab65be --- /dev/null +++ b/server/src/nest/public-api/public-api.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { PublicApiController } from './public-api.controller'; +import { PublicApiService } from './public-api.service'; +import { ApiTokenGuard } from './api-token.guard'; +import { TokensModule } from '../tokens/tokens.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; + +/** + * Public API v1 — the versioned read-only surface for third-party integrations. + * + * Imports rather than re-implements: token verification comes from TokensModule, + * trip access from TripMembershipModule and DatabaseService. This module owns the + * payload shaping and nothing else, so there is no second place where "who may read + * this trip" is decided. + */ +@Module({ + imports: [TokensModule, TripMembershipModule], + controllers: [PublicApiController], + providers: [PublicApiService, ApiTokenGuard], +}) +export class PublicApiModule {} diff --git a/server/src/nest/public-api/public-api.service.ts b/server/src/nest/public-api/public-api.service.ts new file mode 100644 index 000000000..597cca2a4 --- /dev/null +++ b/server/src/nest/public-api/public-api.service.ts @@ -0,0 +1,307 @@ +import { Injectable } from '@nestjs/common'; +import type { + PublicApiAccommodation, + PublicApiDay, + PublicApiDayNote, + PublicApiInclude, + PublicApiPlace, + PublicApiReservation, + PublicApiTrip, + PublicApiTripSummary, +} from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; +import { TripMembershipService } from '../trip-membership/trip-membership.service'; + +/** + * Assembles the read-only public API payloads. + * + * Two rules run through everything here: + * + * 1. **Access is decided per trip, against the database, every time.** The list + * comes from `listAccessibleTripIds`, a single trip goes through + * `canAccessTrip` — the same predicates the rest of TREK uses (owner or member). + * Nothing is filtered in application code after a broad read, because a filter + * that is forgotten once leaks everything. + * 2. **Rows are never handed out as they are stored.** Ids, foreign keys and + * ordering columns stay inside; the caller gets resolved names, dates and times. + * That keeps the contract stable when the tables move, and it keeps internal + * structure — which user owns what, how ids are numbered — out of the response. + * + * The child queries are scoped by `trip_id` in SQL rather than by filtering a + * wider result set, so a bug in the include handling cannot widen what a caller + * sees; at worst it returns less. + */ +@Injectable() +export class PublicApiService { + constructor( + private readonly db: DatabaseService, + private readonly membership: TripMembershipService, + ) {} + + /** Every trip the token's owner may read, newest first, without itineraries. */ + listTrips(userId: number): PublicApiTripSummary[] { + const ids = this.membership.listAccessibleTripIds(userId); + if (ids.length === 0) return []; + const rows = this.db.all( + `SELECT id, title, description, start_date, end_date, currency, is_archived + FROM trips + WHERE id IN (${ids.map(() => '?').join(',')}) + ORDER BY start_date DESC, id DESC`, + ...ids, + ); + return rows.map(toTripSummary); + } + + /** + * One trip with the requested sections, or null when the caller may not read it. + * + * Null covers both "no such trip" and "not yours" on purpose — the controller + * turns both into the same 404, so the endpoint cannot be used to probe which + * trip ids exist. + */ + getTrip(tripId: number, userId: number, include: PublicApiInclude[]): PublicApiTrip | null { + if (!this.db.canAccessTrip(tripId, userId)) return null; + const row = this.db.get( + `SELECT id, title, description, start_date, end_date, currency, is_archived + FROM trips WHERE id = ?`, + tripId, + ); + if (!row) return null; + + const trip: PublicApiTrip = toTripSummary(row); + if (include.includes('days')) { + trip.days = this.buildDays(tripId, include); + } + if (include.includes('accommodations')) { + trip.accommodations = this.buildAccommodations(tripId); + } + return trip; + } + + /** + * The itinerary. Days are the spine: everything dated hangs off one, which is + * what lets a consumer join on `date` alone. + * + * The per-day children are fetched once for the whole trip and grouped in memory + * rather than queried per day — a two-week trip would otherwise cost 42 round + * trips for the same rows. + */ + private buildDays(tripId: number, include: PublicApiInclude[]): PublicApiDay[] { + const days = this.db.all( + `SELECT id, day_number, date, title, notes + FROM days WHERE trip_id = ? ORDER BY day_number ASC`, + tripId, + ); + if (days.length === 0) return []; + + const placesByDay = include.includes('places') ? this.placesByDay(tripId) : new Map(); + const notesByDay = include.includes('notes') ? this.dayNotesByDay(tripId) : new Map(); + const reservationsByDay = include.includes('reservations') + ? this.reservationsByDay(tripId) + : new Map(); + + return days.map((day) => ({ + date: day.date, + day_number: day.day_number, + title: day.title ?? null, + notes: day.notes ?? null, + places: placesByDay.get(day.id) ?? [], + day_notes: notesByDay.get(day.id) ?? [], + reservations: reservationsByDay.get(day.id) ?? [], + })); + } + + /** + * Places in the order the traveller planned to visit them. + * + * `order_index` decides the sequence and is then dropped: it is a storage detail + * that only means something relative to its siblings, and an array already + * carries order. + */ + private placesByDay(tripId: number): Map { + const rows = this.db.all( + `SELECT da.day_id, + p.name, p.address, p.lat, p.lng, p.place_time, p.end_time, + p.duration_minutes, p.notes, p.transport_mode, + c.name AS category + FROM day_assignments da + JOIN places p ON p.id = da.place_id + LEFT JOIN categories c ON c.id = p.category_id + WHERE p.trip_id = ? + ORDER BY da.day_id ASC, da.order_index ASC`, + tripId, + ); + return groupBy(rows, (r) => r.day_id, (r) => ({ + name: r.name, + address: r.address ?? null, + lat: r.lat ?? null, + lng: r.lng ?? null, + time: r.place_time ?? null, + end_time: r.end_time ?? null, + duration_minutes: r.duration_minutes ?? null, + category: r.category ?? null, + notes: r.notes ?? null, + transport_mode: r.transport_mode ?? null, + })); + } + + private dayNotesByDay(tripId: number): Map { + const rows = this.db.all( + `SELECT day_id, text, time + FROM day_notes WHERE trip_id = ? + ORDER BY day_id ASC, sort_order ASC`, + tripId, + ); + return groupBy(rows, (r) => r.day_id, (r) => ({ + text: r.text, + time: r.time ?? null, + })); + } + + /** + * Bookings, reported on their starting day. + * + * A reservation may span days (`end_day_id`), but it is listed once rather than + * repeated on each — a consumer that sees the same flight on three days has no + * way to tell that from three flights. + */ + private reservationsByDay(tripId: number): Map { + const rows = this.db.all( + `SELECT day_id, type, title, location, reservation_time, reservation_end_time, + status, notes + FROM reservations + WHERE trip_id = ? AND day_id IS NOT NULL + ORDER BY day_id ASC, reservation_time ASC`, + tripId, + ); + return groupBy(rows, (r) => r.day_id, (r) => ({ + type: r.type ?? null, + title: r.title ?? null, + location: r.location ?? null, + time: r.reservation_time ?? null, + end_time: r.reservation_end_time ?? null, + status: r.status ?? null, + notes: r.notes ?? null, + })); + } + + /** + * Accommodations with their date range resolved from the start/end day rows. + * + * Stored as day ids, reported as ISO dates: a consumer has no way to look up a + * TREK day id, and the dates are what it actually needs to match its own nights. + */ + private buildAccommodations(tripId: number): PublicApiAccommodation[] { + const rows = this.db.all( + `SELECT p.name, p.address, p.lat, p.lng, + ds.date AS start_date, de.date AS end_date, + a.check_in, a.check_out, a.notes + FROM day_accommodations a + LEFT JOIN places p ON p.id = a.place_id + LEFT JOIN days ds ON ds.id = a.start_day_id + LEFT JOIN days de ON de.id = a.end_day_id + WHERE a.trip_id = ? + ORDER BY ds.date ASC`, + tripId, + ); + return rows.map((r) => ({ + name: r.name ?? null, + address: r.address ?? null, + lat: r.lat ?? null, + lng: r.lng ?? null, + start_date: r.start_date ?? null, + end_date: r.end_date ?? null, + check_in: r.check_in ?? null, + check_out: r.check_out ?? null, + notes: r.notes ?? null, + })); + } +} + +function toTripSummary(row: TripRow): PublicApiTripSummary { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + start_date: row.start_date ?? null, + end_date: row.end_date ?? null, + currency: row.currency ?? null, + archived: row.is_archived === 1, + }; +} + +function groupBy( + rows: Row[], + key: (row: Row) => number, + map: (row: Row) => Out, +): Map { + const grouped = new Map(); + for (const row of rows) { + const id = key(row); + const bucket = grouped.get(id); + if (bucket) bucket.push(map(row)); + else grouped.set(id, [map(row)]); + } + return grouped; +} + +interface TripRow { + id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + is_archived: number | null; +} + +interface DayRow { + id: number; + day_number: number; + date: string; + title: string | null; + notes: string | null; +} + +interface PlaceRow { + day_id: number; + name: string; + address: string | null; + lat: number | null; + lng: number | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + notes: string | null; + transport_mode: string | null; + category: string | null; +} + +interface DayNoteRow { + day_id: number; + text: string; + time: string | null; +} + +interface ReservationRow { + day_id: number; + type: string | null; + title: string | null; + location: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + status: string | null; + notes: string | null; +} + +interface AccommodationRow { + name: string | null; + address: string | null; + lat: number | null; + lng: number | null; + start_date: string | null; + end_date: string | null; + check_in: string | null; + check_out: string | null; + notes: string | null; +} diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts new file mode 100644 index 000000000..89a7e6df3 --- /dev/null +++ b/server/tests/e2e/public-api.e2e.test.ts @@ -0,0 +1,273 @@ +/** + * Public API v1 e2e — the real ApiTokenGuard and real SQL against a temp SQLite db. + * + * The unit tests pin the shaping; this one exists for the question a mock cannot + * answer: does a token actually only reach its owner's trips? Two users, two trips, + * and every path that could leak one into the other is exercised — the list, the + * detail route, and a trip shared by membership (which must be visible, because + * that is TREK's access model, not an exception to it). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { createHash } from 'crypto'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(` + CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0); + CREATE TABLE mcp_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME); + CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + title TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, currency TEXT, + is_archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL); + CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + day_number INTEGER NOT NULL, date TEXT NOT NULL, notes TEXT, title TEXT); + CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + name TEXT NOT NULL, address TEXT, lat REAL, lng REAL, category_id INTEGER, + place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, transport_mode TEXT); + CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL); + CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0); + CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + trip_id INTEGER NOT NULL, text TEXT NOT NULL, time TEXT, icon TEXT, sort_order REAL DEFAULT 0); + CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT, + check_out TEXT, confirmation TEXT, notes TEXT); + CREATE TABLE reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + day_id INTEGER, end_day_id INTEGER, place_id INTEGER, assignment_id INTEGER, title TEXT, + accommodation_id TEXT, reservation_time TEXT, reservation_end_time TEXT, location TEXT, + confirmation_number TEXT, notes TEXT, status TEXT, type TEXT); + `); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + db, + closeDb: () => {}, + reinitialize: () => {}, + canAccessTrip: (tripId: number | string, userId: number) => + db + .prepare( + `SELECT t.id, t.user_id, t.currency 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), + }; +}); + +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { PublicApiModule } from '../../src/nest/public-api/public-api.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +/** Mints a token the way TokenService does, so the guard's hash lookup is real. */ +function seedToken(userId: number, raw: string): string { + db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)').run( + userId, + 'test', + createHash('sha256').update(raw).digest('hex'), + raw.slice(0, 13), + ); + return raw; +} + +const ADA_TOKEN = 'trek_' + 'a'.repeat(48); +const BOB_TOKEN = 'trek_' + 'b'.repeat(48); + +describe('Public API v1 e2e (real guard + real SQL)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PublicApiModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + db.prepare("INSERT INTO users (id, username, email) VALUES (1, 'ada', 'ada@example.com')").run(); + db.prepare("INSERT INTO users (id, username, email) VALUES (2, 'bob', 'bob@example.com')").run(); + seedToken(1, ADA_TOKEN); + seedToken(2, BOB_TOKEN); + + // Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member. + db.prepare( + "INSERT INTO trips (id, user_id, title, description, start_date, end_date, currency) VALUES (1, 1, 'Toskana', 'Wein', '2026-06-14', '2026-06-16', 'EUR')", + ).run(); + db.prepare("INSERT INTO trips (id, user_id, title, start_date) VALUES (2, 2, 'Bobs Secret', '2026-07-01')").run(); + db.prepare("INSERT INTO trips (id, user_id, title, start_date) VALUES (3, 2, 'Shared', '2026-08-01')").run(); + db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (3, 1)').run(); + + db.prepare("INSERT INTO days (id, trip_id, day_number, date, title) VALUES (1, 1, 1, '2026-06-14', 'Ankunft')").run(); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (2, 1, 2, '2026-06-15')").run(); + db.prepare("INSERT INTO categories (id, name) VALUES (1, 'Museum')").run(); + db.prepare( + "INSERT INTO places (id, trip_id, name, address, lat, lng, category_id, place_time, duration_minutes, transport_mode) VALUES (1, 1, 'Uffizien', 'Firenze', 43.76, 11.25, 1, '14:00', 180, 'walking')", + ).run(); + db.prepare("INSERT INTO places (id, trip_id, name, lat, lng) VALUES (2, 1, 'Ponte Vecchio', 43.76, 11.24)").run(); + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (3, 1, 'Hotel Alba')").run(); + // Deliberately inserted out of order to prove order_index decides the sequence. + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 2, 1)').run(); + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 1, 0)').run(); + db.prepare("INSERT INTO day_notes (day_id, trip_id, text, time, sort_order) VALUES (1, 1, 'Tickets mitnehmen', '09:00', 0)").run(); + db.prepare( + "INSERT INTO reservations (trip_id, day_id, type, title, location, reservation_time, status) VALUES (1, 1, 'flight', 'LH 1234', 'FRA', '2026-06-14T08:00', 'confirmed')", + ).run(); + db.prepare( + "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (1, 3, 1, 2, '15:00', '11:00')", + ).run(); + + app = await build(); + server = app.getHttpServer() as Server; + }); + + afterAll(async () => { + await app?.close(); + }); + + const get = (path: string, token?: string) => { + const req = request(server).get(path); + return token ? req.set('Authorization', `Bearer ${token}`) : req; + }; + + describe('authentication', () => { + it('401s without a token', async () => { + const res = await get('/api/v1/trips'); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }); + }); + + it('401s on an unknown token', async () => { + expect((await get('/api/v1/trips', 'trek_' + 'z'.repeat(48))).status).toBe(401); + }); + + it('accepts the token via X-API-Key too', async () => { + const res = await request(server).get('/api/v1/trips').set('X-API-Key', ADA_TOKEN); + expect(res.status).toBe(200); + }); + + it('records last_used_at so a stale token is visible in settings', async () => { + await get('/api/v1/trips', ADA_TOKEN); + const row = db.prepare('SELECT last_used_at FROM mcp_tokens WHERE user_id = 1').get() as { + last_used_at: string | null; + }; + expect(row.last_used_at).not.toBeNull(); + }); + }); + + /** The isolation tests. Everything else is convenience; these are the point. */ + describe('access isolation', () => { + it("lists only the caller's own and shared trips", async () => { + const res = await get('/api/v1/trips', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([1, 3]); + }); + + it("does not leak another user's trip into the list", async () => { + const res = await get('/api/v1/trips', BOB_TOKEN); + expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([2, 3]); + expect(JSON.stringify(res.body)).not.toContain('Toskana'); + }); + + it("404s on another user's trip, identically to one that does not exist", async () => { + const foreign = await get('/api/v1/trips/2', ADA_TOKEN); + const missing = await get('/api/v1/trips/424242', ADA_TOKEN); + expect(foreign.status).toBe(404); + expect(foreign.body).toEqual({ error: 'Trip not found' }); + expect(missing.status).toBe(foreign.status); + expect(missing.body).toEqual(foreign.body); + }); + + it('serves a trip shared by membership, because that is the access model', async () => { + const res = await get('/api/v1/trips/3', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.title).toBe('Shared'); + }); + }); + + describe('payload', () => { + it('returns the whole itinerary by default, in planned order', async () => { + const res = await get('/api/v1/trips/1', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: 1, + title: 'Toskana', + description: 'Wein', + start_date: '2026-06-14', + currency: 'EUR', + archived: false, + }); + expect(res.body.days).toHaveLength(2); + + const [first] = res.body.days; + expect(first.date).toBe('2026-06-14'); + expect(first.title).toBe('Ankunft'); + expect(first.places.map((p: { name: string }) => p.name)).toEqual(['Uffizien', 'Ponte Vecchio']); + expect(first.places[0]).toMatchObject({ + lat: 43.76, + time: '14:00', + duration_minutes: 180, + category: 'Museum', + transport_mode: 'walking', + }); + expect(first.day_notes).toEqual([{ text: 'Tickets mitnehmen', time: '09:00' }]); + expect(first.reservations[0]).toMatchObject({ type: 'flight', title: 'LH 1234', status: 'confirmed' }); + }); + + it('resolves accommodation day ids into ISO dates', async () => { + const res = await get('/api/v1/trips/1', ADA_TOKEN); + expect(res.body.accommodations).toEqual([ + { + name: 'Hotel Alba', + address: null, + lat: null, + lng: null, + start_date: '2026-06-14', + end_date: '2026-06-15', + check_in: '15:00', + check_out: '11:00', + notes: null, + }, + ]); + }); + + it('never exposes internal ids or foreign keys', async () => { + const res = await get('/api/v1/trips/1', ADA_TOKEN); + const serialised = JSON.stringify(res.body); + for (const leak of ['user_id', 'day_id', 'place_id', 'order_index', 'trip_id', 'assignment_id']) { + expect(serialised).not.toContain(leak); + } + }); + + it('omits sections the caller did not ask for, rather than sending them empty', async () => { + const res = await get('/api/v1/trips/1?include=days,notes', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.accommodations).toBeUndefined(); + expect(res.body.days[0].day_notes).toHaveLength(1); + expect(res.body.days[0].places).toEqual([]); + expect(res.body.days[0].reservations).toEqual([]); + }); + + it('400s on an unknown include section', async () => { + const res = await get('/api/v1/trips/1?include=days,nope', ADA_TOKEN); + expect(res.status).toBe(400); + }); + + it('400s on a non-numeric trip id', async () => { + expect((await get('/api/v1/trips/abc', ADA_TOKEN)).status).toBe(400); + }); + }); +}); diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts new file mode 100644 index 000000000..a94a5429d --- /dev/null +++ b/server/tests/unit/nest/public-api.controller.test.ts @@ -0,0 +1,128 @@ +/** + * PublicApiController — request shaping for /api/v1. + * + * The controller owns three decisions and the tests below pin all three: how a + * trip id is parsed, how `include` is validated, and what a caller is told about a + * trip they may not read (nothing that distinguishes it from one that does not + * exist). + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import type { Request } from 'express'; +import { PUBLIC_API_INCLUDES } from '@trek/shared'; +import { PublicApiController } from '../../../src/nest/public-api/public-api.controller'; +import type { PublicApiService } from '../../../src/nest/public-api/public-api.service'; + +const TRIP = { + id: 12, + title: 'Toskana', + description: null, + start_date: '2026-06-14', + end_date: '2026-06-22', + currency: 'EUR', + archived: false, +}; + +function makeController(svc: Partial) { + return new PublicApiController(svc as PublicApiService); +} + +const req = (userId = 7) => ({ user: { id: userId } }) as Request; +/** A request the guard never touched — the shape the controller must refuse. */ +const reqWithoutUser = () => ({}) as Request; + +function thrown(fn: () => unknown): { status: number; body: unknown } { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to throw'); +} + +describe('PublicApiController', () => { + describe('GET /api/v1/trips', () => { + it('returns the accessible trips for the token owner', () => { + const listTrips = vi.fn().mockReturnValue([TRIP]); + expect(makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [TRIP] }); + expect(listTrips).toHaveBeenCalledWith(7); + }); + + it('returns an empty list rather than 404 when the user has no trips', () => { + const listTrips = vi.fn().mockReturnValue([]); + expect(makeController({ listTrips }).listTrips(req(7))).toEqual({ trips: [] }); + }); + + it('401s if the guard was somehow bypassed and no user is attached', () => { + const listTrips = vi.fn(); + expect(thrown(() => makeController({ listTrips }).listTrips(reqWithoutUser()))).toEqual({ + status: 401, + body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + }); + expect(listTrips).not.toHaveBeenCalled(); + }); + }); + + describe('GET /api/v1/trips/:id', () => { + it('passes the parsed id and user through, and defaults include to every section', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + const res = makeController({ getTrip }).getTrip(req(7), '12', undefined); + expect(res).toEqual(TRIP); + expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES]); + }); + + it('treats an empty include the same as an absent one', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + makeController({ getTrip }).getTrip(req(7), '12', ' '); + expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES]); + }); + + it('narrows to the requested sections and tolerates spacing', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + makeController({ getTrip }).getTrip(req(7), '12', 'days, notes'); + expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']); + }); + + /** + * The one that matters: a trip belonging to someone else answers exactly like a + * trip that was never created. Anything else turns the endpoint into a way to + * count another user's trips. + */ + it('404s for a trip the caller may not read, with no hint that it exists', () => { + const getTrip = vi.fn().mockReturnValue(null); + expect(thrown(() => makeController({ getTrip }).getTrip(req(7), '999', undefined))).toEqual({ + status: 404, + body: { error: 'Trip not found' }, + }); + }); + + it.each([ + ['a non-numeric id', 'abc'], + ['a numeric prefix', '12abc'], + ['a negative id', '-1'], + ['zero', '0'], + ['a float', '1.5'], + ['an empty id', ''], + ['whitespace', ' 12 '], + ['a sql fragment', "1 OR 1=1"], + ['an id past the safe integer range', '9007199254740993'], + ])('400s on %s without touching the service', (_label, raw) => { + const getTrip = vi.fn(); + expect(thrown(() => makeController({ getTrip }).getTrip(req(7), raw, undefined)).status).toBe(400); + expect(getTrip).not.toHaveBeenCalled(); + }); + + it.each([ + ['an unknown section', 'days,everything'], + ['a typo', 'dayz'], + ['only commas', ',,,'], + ])('400s on %s rather than silently dropping it', (_label, include) => { + const getTrip = vi.fn(); + const res = thrown(() => makeController({ getTrip }).getTrip(req(7), '12', include)); + expect(res.status).toBe(400); + expect(getTrip).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts new file mode 100644 index 000000000..042297d08 --- /dev/null +++ b/server/tests/unit/nest/public-api.guard.test.ts @@ -0,0 +1,109 @@ +/** + * ApiTokenGuard — the only door into /api/v1. + * + * The cases that matter here are the refusals. A guard that lets the right token + * through is easy; one that also lets a session JWT, an OAuth bearer or a random + * string through is a credential-confusion bug, and those are the tests below. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard'; +import type { TokenService } from '../../../src/nest/tokens/token.service'; +import type { User } from '../../../src/types'; + +const USER: User = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User; + +function contextWith(headers: Record) { + const req: Record = { headers }; + return { + switchToHttp: () => ({ getRequest: () => req }), + req, + } as unknown as ExecutionContext & { req: Record }; +} + +function makeGuard(verify: (raw: string) => User | null) { + return new ApiTokenGuard({ verifyMcpToken: verify } as unknown as TokenService); +} + +/** Run the guard, expecting a refusal; return its { status, body }. */ +function refused(fn: () => boolean): { status: number; body: unknown } { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the guard to refuse'); +} + +describe('ApiTokenGuard', () => { + it('accepts a trek_ token via Authorization: Bearer and resolves the user', () => { + const verify = vi.fn().mockReturnValue(USER); + const ctx = contextWith({ authorization: 'Bearer trek_abc123' }); + expect(makeGuard(verify).canActivate(ctx)).toBe(true); + expect(verify).toHaveBeenCalledWith('trek_abc123'); + expect(ctx.req.user).toBe(USER); + }); + + it('accepts the same token via X-API-Key', () => { + const verify = vi.fn().mockReturnValue(USER); + const ctx = contextWith({ 'x-api-key': 'trek_abc123' }); + expect(makeGuard(verify).canActivate(ctx)).toBe(true); + expect(verify).toHaveBeenCalledWith('trek_abc123'); + }); + + it('trims surrounding whitespace before looking a token up', () => { + const verify = vi.fn().mockReturnValue(USER); + makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' })); + expect(verify).toHaveBeenCalledWith('trek_abc123'); + }); + + it('401s without any credential', () => { + const verify = vi.fn(); + expect(refused(() => makeGuard(verify).canActivate(contextWith({})))).toEqual({ + status: 401, + body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + }); + expect(verify).not.toHaveBeenCalled(); + }); + + it('401s on a token the store does not know, without saying which part was wrong', () => { + const verify = vi.fn().mockReturnValue(null); + expect( + refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), + ).toEqual({ + status: 401, + body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, + }); + }); + + /** + * The credential-confusion cases. Each of these is a valid credential somewhere + * else in TREK, and none of them may open this door — a session cookie that ends + * up in a third-party integration is precisely what a machine token prevents. + */ + it.each([ + ['a session JWT', { authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' }], + ['an OAuth bearer', { authorization: 'Bearer mcp_at_9f3a2b' }], + ['a bare random string', { authorization: 'Bearer hunter2' }], + ['a non-Bearer scheme', { authorization: 'Basic dHJlazpwdw==' }], + ['a trek_ token in the wrong scheme', { authorization: 'Token trek_abc123' }], + ['an X-API-Key that is not a trek_ token', { 'x-api-key': 'eyJhbGciOiJIUzI1NiJ9.p.s' }], + ])('refuses %s without ever hitting the token store', (_label, headers) => { + const verify = vi.fn(); + expect(refused(() => makeGuard(verify).canActivate(contextWith(headers)))).toEqual({ + status: 401, + body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + }); + expect(verify).not.toHaveBeenCalled(); + }); + + it('ignores an array-valued Authorization header rather than coercing it', () => { + const verify = vi.fn(); + const ctx = contextWith({ authorization: ['Bearer trek_abc'] as unknown as string }); + expect(refused(() => makeGuard(verify).canActivate(ctx)).status).toBe(401); + expect(verify).not.toHaveBeenCalled(); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index 775d7d744..6ae29d239 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,6 +13,7 @@ export * from './common/pagination.schema'; // Domain contracts export * from './weather/weather.schema'; +export * from './public-api/public-api.schema'; export * from './airport/airport.schema'; export * from './config/config.schema'; export * from './system-notice/system-notice.schema'; diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts new file mode 100644 index 000000000..4a49dd008 --- /dev/null +++ b/shared/src/public-api/public-api.schema.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; + +/** + * Public API v1 contract — the read-only surface third-party integrations bind to. + * + * Everything else in TREK's REST surface is internal: it answers a session cookie, + * carries no version, and changes whenever the client changes. This is the opposite + * promise. Once an integration ships against `/api/v1`, these shapes are a contract, + * so the rules are: fields may be added, never removed or retyped, and a breaking + * change gets a new version rather than an edit here. + * + * The shapes are deliberately NOT the internal row types. A row carries ids, foreign + * keys and storage details a consumer has no business depending on (`order_index`, + * `assignment_id`, the accommodation's `place_id`); those are resolved here instead, + * so the payload survives a refactor of the tables underneath. + * + * Every dated thing carries `date` in ISO `YYYY-MM-DD`. That is the join key for a + * consumer that keeps its own notion of a trip — location trackers bucket points by + * day, and matching on a date is the only thing both sides can agree on without + * sharing ids. + */ + +/** A place as it sits on a day, with the day's ordering already applied. */ +export const publicApiPlaceSchema = z.object({ + name: z.string(), + address: z.string().nullable(), + /** Null for a place the user never geocoded — plenty of them exist. */ + lat: z.number().nullable(), + lng: z.number().nullable(), + /** `HH:MM`, null when the stop is not pinned to a time. */ + time: z.string().nullable(), + end_time: z.string().nullable(), + duration_minutes: z.number().nullable(), + category: z.string().nullable(), + notes: z.string().nullable(), + /** How the traveller reaches this stop from the previous one. */ + transport_mode: z.string().nullable(), +}); +export type PublicApiPlace = z.infer; + +/** A free-text note pinned to a day, optionally to a time within it. */ +export const publicApiDayNoteSchema = z.object({ + text: z.string(), + time: z.string().nullable(), +}); +export type PublicApiDayNote = z.infer; + +/** + * A booking. `type` is TREK's own vocabulary (flight, train, restaurant, …) and is + * passed through unmapped: inventing a canonical enum here would silently drop the + * types a consumer might actually care about. + */ +export const publicApiReservationSchema = z.object({ + type: z.string().nullable(), + title: z.string().nullable(), + location: z.string().nullable(), + /** ISO timestamp or `HH:MM`, exactly as the user entered it. */ + time: z.string().nullable(), + end_time: z.string().nullable(), + status: z.string().nullable(), + notes: z.string().nullable(), +}); +export type PublicApiReservation = z.infer; + +/** + * Where the traveller sleeps. Spans days, so it is reported once at trip level with + * its own date range rather than duplicated onto every night it covers. + */ +export const publicApiAccommodationSchema = z.object({ + name: z.string().nullable(), + address: z.string().nullable(), + lat: z.number().nullable(), + lng: z.number().nullable(), + /** First and last night as ISO dates, resolved from the start/end day rows. */ + start_date: z.string().nullable(), + end_date: z.string().nullable(), + check_in: z.string().nullable(), + check_out: z.string().nullable(), + notes: z.string().nullable(), +}); +export type PublicApiAccommodation = z.infer; + +/** One day of the itinerary. `date` is the join key for consumers. */ +export const publicApiDaySchema = z.object({ + date: z.string(), + day_number: z.number(), + title: z.string().nullable(), + notes: z.string().nullable(), + places: z.array(publicApiPlaceSchema), + day_notes: z.array(publicApiDayNoteSchema), + reservations: z.array(publicApiReservationSchema), +}); +export type PublicApiDay = z.infer; + +/** Trip without its itinerary — what the list endpoint returns. */ +export const publicApiTripSummarySchema = z.object({ + id: z.number(), + title: z.string(), + description: z.string().nullable(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), + currency: z.string().nullable(), + archived: z.boolean(), +}); +export type PublicApiTripSummary = z.infer; + +/** + * A whole trip. The `days`, `accommodations` and per-day sections are present only + * when the caller asked for them via `include`; an omitted section is absent rather + * than empty, so a consumer can tell "not requested" from "nothing there". + */ +export const publicApiTripSchema = publicApiTripSummarySchema.extend({ + days: z.array(publicApiDaySchema).optional(), + accommodations: z.array(publicApiAccommodationSchema).optional(), +}); +export type PublicApiTrip = z.infer; + +/** + * The sections a caller may ask for. Defaults to everything, because the common + * case is a full sync; a consumer that only wants notes says so and gets a payload + * a fraction of the size. + */ +export const PUBLIC_API_INCLUDES = ['days', 'places', 'notes', 'reservations', 'accommodations'] as const; +export type PublicApiInclude = (typeof PUBLIC_API_INCLUDES)[number]; + +/** + * `?include=days,notes` — comma-separated, unknown values rejected rather than + * ignored, so a typo surfaces as a 400 instead of a silently missing section. + * Absent means all sections. + */ +export const publicApiIncludeQuerySchema = z + .string() + .transform((raw) => raw.split(',').map((part) => part.trim()).filter(Boolean)) + .pipe(z.array(z.enum(PUBLIC_API_INCLUDES)).min(1)); + +export const publicApiTripListSchema = z.object({ + trips: z.array(publicApiTripSummarySchema), +}); +export type PublicApiTripList = z.infer;