mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user