feat(api): add a versioned read-only public API for integrations

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.
This commit is contained in:
Maurice
2026-08-28 01:03:02 +02:00
committed by Maurice
parent d2402c798c
commit fd6381c814
11 changed files with 1163 additions and 2 deletions
+2 -1
View File
@@ -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
@@ -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.
@@ -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<Request>();
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;
}
@@ -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;
}
@@ -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 {}
@@ -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<TripRow>(
`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<TripRow>(
`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<DayRow>(
`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<number, PublicApiPlace[]> {
const rows = this.db.all<PlaceRow>(
`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<number, PublicApiDayNote[]> {
const rows = this.db.all<DayNoteRow>(
`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<number, PublicApiReservation[]> {
const rows = this.db.all<ReservationRow>(
`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<AccommodationRow>(
`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<Row, Out>(
rows: Row[],
key: (row: Row) => number,
map: (row: Row) => Out,
): Map<number, Out[]> {
const grouped = new Map<number, Out[]>();
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;
}
+273
View File
@@ -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<typeof import('../../src/db/database')>();
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<ReturnType<typeof build>>;
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);
});
});
});
@@ -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<PublicApiService>) {
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();
});
});
});
@@ -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<string, string | undefined>) {
const req: Record<string, unknown> = { headers };
return {
switchToHttp: () => ({ getRequest: () => req }),
req,
} as unknown as ExecutionContext & { req: Record<string, unknown> };
}
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();
});
});
+1
View File
@@ -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';
+139
View File
@@ -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<typeof publicApiPlaceSchema>;
/** 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<typeof publicApiDayNoteSchema>;
/**
* 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<typeof publicApiReservationSchema>;
/**
* 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<typeof publicApiAccommodationSchema>;
/** 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<typeof publicApiDaySchema>;
/** 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<typeof publicApiTripSummarySchema>;
/**
* 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<typeof publicApiTripSchema>;
/**
* 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<typeof publicApiTripListSchema>;