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
+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>;