diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts index 7c7700ee6..7533ed919 100644 --- a/server/src/nest/public-api/public-api.controller.ts +++ b/server/src/nest/public-api/public-api.controller.ts @@ -3,6 +3,7 @@ import type { Request } from 'express'; import { PUBLIC_API_INCLUDES, publicApiIncludeQuerySchema, + type PublicApiBucketList, type PublicApiInclude, type PublicApiTrip, type PublicApiTripList, @@ -63,6 +64,19 @@ export class PublicApiController { return { trips: this.api.listTrips(requireUserId(req)) }; } + /** + * The caller's bucket list: places they want to reach, with no trip attached. + * + * Its own endpoint rather than an `include`, because it hangs off the user and + * not off any trip. For a consumer that knows where someone has actually been, + * this is the one list in TREK it can answer questions about. + */ + @Get('bucket-list') + listBucketList(@Req() req: Request): PublicApiBucketList { + this.limit(req); + return { items: this.api.listBucketList(requireUserId(req)) }; + } + /** * One trip, with the sections named in `?include=`. * @@ -122,6 +136,10 @@ function parseTripId(raw: string): number { * 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. + * + * `places`, `notes` and `reservations` are reported on days, so asking for one of + * them brings `days` along; the service handles that rather than this parser, so + * the echo of what the caller asked for stays honest. */ function parseInclude(raw?: string): PublicApiInclude[] { if (raw === undefined || raw.trim() === '') return [...PUBLIC_API_INCLUDES]; diff --git a/server/src/nest/public-api/public-api.module.ts b/server/src/nest/public-api/public-api.module.ts index 586be8ef8..f1a490575 100644 --- a/server/src/nest/public-api/public-api.module.ts +++ b/server/src/nest/public-api/public-api.module.ts @@ -13,6 +13,11 @@ import { RateLimitModule } from '../common/rate-limit.module'; * 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. + * + * Deliberately a leaf. Pulling in a domain module for one table drags its whole + * graph along: AtlasModule needs AuthModule, which boots the storage registry, + * which reads app_settings on init. A read-only surface that cannot start without + * half the application is one that breaks for reasons it has nothing to do with. */ @Module({ imports: [TokensModule, TripMembershipModule, RateLimitModule], diff --git a/server/src/nest/public-api/public-api.service.ts b/server/src/nest/public-api/public-api.service.ts index 7527aebce..7af29291f 100644 --- a/server/src/nest/public-api/public-api.service.ts +++ b/server/src/nest/public-api/public-api.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import type { PublicApiAccommodation, + PublicApiBucketListItem, PublicApiDay, PublicApiDayNote, PublicApiInclude, @@ -70,9 +71,18 @@ export class PublicApiService { if (!row) return null; const trip: PublicApiTrip = toTripSummary(row); - if (include.includes('days')) { + // Places, notes and reservations hang off days, so asking for one of them + // and not for `days` used to return the trip and nothing else — silently, + // which is the worst way to answer. Days are implied instead. + if (DAY_SCOPED.some((section) => include.includes(section))) { trip.days = this.buildDays(tripId, include); } + if (include.includes('places')) { + trip.unplanned_places = this.buildUnplannedPlaces(tripId); + } + if (include.includes('reservations')) { + trip.unscheduled_reservations = this.buildUnscheduledReservations(tripId); + } if (include.includes('accommodations')) { trip.accommodations = this.buildAccommodations(tripId); } @@ -135,18 +145,7 @@ export class PublicApiService { 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, - })); + return groupBy(rows, (r: PlaceRow) => r.day_id, toPlace); } private dayNotesByDay(tripId: number): Map { @@ -178,15 +177,7 @@ export class PublicApiService { 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, - })); + return groupBy(rows, (r: ReservationRow) => r.day_id, toReservation); } /** @@ -221,6 +212,84 @@ export class PublicApiService { })); } + /** + * Places the traveller collected but has not scheduled: no row in + * `day_assignments`, so they belong to the trip rather than to any day. + * + * These are not leftovers. On a real instance roughly half the places on a + * trip sit here, and they are the ones a consumer can most usefully act on: + * somewhere the traveller wants to go, with a coordinate, and no claim yet + * about when. + * + * Ordered by creation, which is the only order they have: an unscheduled place + * has no position relative to its siblings. + * + * Hotels are excluded. An accommodation's place has no day assignment either, + * but it is not a shortlist entry and it is already reported in full under + * `accommodations` — listing it twice would read as two different intentions. + */ + private buildUnplannedPlaces(tripId: number): PublicApiPlace[] { + const rows = this.db.all>( + `SELECT 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 places p + LEFT JOIN categories c ON c.id = p.category_id + WHERE p.trip_id = ? + AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.place_id = p.id) + AND NOT EXISTS (SELECT 1 FROM day_accommodations a WHERE a.place_id = p.id) + ORDER BY p.created_at ASC, p.id ASC`, + tripId, + ); + return rows.map(toPlace); + } + + /** + * Bookings with no day. `reservations.day_id` is nullable and a deleted day + * sets it null rather than cascading, so a flight can outlive the day it was + * pinned to. Reporting only day-bound bookings would quietly lose those. + */ + private buildUnscheduledReservations(tripId: number): PublicApiReservation[] { + const rows = this.db.all>( + `SELECT type, title, location, reservation_time, reservation_end_time, + status, notes + FROM reservations + WHERE trip_id = ? AND day_id IS NULL + ORDER BY reservation_time ASC, id ASC`, + tripId, + ); + return rows.map(toReservation); + } + + /** + * The caller's bucket list: places they want to reach, with no trip attached. + * + * Read here rather than through AtlasService, for the same reason every other + * table in this file is: importing a domain module for one SELECT drags its + * whole graph in, and this surface has to be able to boot on its own. The query + * is scoped by `user_id` in SQL, which is the part that matters. + * + * Returns entries even when the Atlas addon is switched off: the addon governs + * whether TREK shows the feature, not whether the rows exist, and a key whose + * answers change when an unrelated toggle moves is a key nobody can build on. + */ + listBucketList(userId: number): PublicApiBucketListItem[] { + const rows = this.db.all( + `SELECT name, lat, lng, country_code, notes, target_date + FROM bucket_list WHERE user_id = ? + ORDER BY created_at DESC, id DESC`, + userId, + ); + return rows.map((r) => ({ + name: r.name, + lat: r.lat ?? null, + lng: r.lng ?? null, + country_code: r.country_code ?? null, + notes: r.notes ?? null, + target_date: r.target_date ?? null, + })); + } + /** * Who is on the trip: the owner first, then members in join order. * @@ -245,6 +314,44 @@ export class PublicApiService { } } +/** + * Sections that live on a day. Asking for any of them implies `days`, because + * that is where they are reported. + */ +const DAY_SCOPED: PublicApiInclude[] = ['days', 'places', 'notes', 'reservations']; + +/** + * One definition of what a place looks like on the wire, used both for places on + * a day and for unscheduled ones. Two copies would drift, and the second copy is + * always the one that forgets a field. + */ +function toPlace(row: Omit): PublicApiPlace { + return { + name: row.name, + address: row.address ?? null, + lat: row.lat ?? null, + lng: row.lng ?? null, + time: row.place_time ?? null, + end_time: row.end_time ?? null, + duration_minutes: row.duration_minutes ?? null, + category: row.category ?? null, + notes: row.notes ?? null, + transport_mode: row.transport_mode ?? null, + }; +} + +function toReservation(row: Omit): PublicApiReservation { + return { + type: row.type ?? null, + title: row.title ?? null, + location: row.location ?? null, + time: row.reservation_time ?? null, + end_time: row.reservation_end_time ?? null, + status: row.status ?? null, + notes: row.notes ?? null, + }; +} + function toTripSummary(row: TripRow): PublicApiTripSummary { return { id: row.id, @@ -339,3 +446,12 @@ interface AccommodationRow { check_out: string | null; notes: string | null; } + +interface BucketListRow { + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + target_date: string | null; +} diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts index 61893327c..2c45b8076 100644 --- a/server/tests/e2e/public-api.e2e.test.ts +++ b/server/tests/e2e/public-api.e2e.test.ts @@ -34,7 +34,8 @@ const { db } = vi.hoisted(() => { 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); + place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, transport_mode TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP); 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); @@ -47,6 +48,9 @@ const { db } = vi.hoisted(() => { 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); + CREATE TABLE bucket_list (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + name TEXT NOT NULL, lat REAL, lng REAL, country_code TEXT, notes TEXT, + target_date TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); `); return { db: tmp }; }); @@ -128,6 +132,12 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { 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(); + // Ein Ort auf der Shortlist: Koordinaten, aber noch kein Tag. + db.prepare("INSERT INTO places (id, trip_id, name, lat, lng, notes) VALUES (4, 1, 'Boboli-Garten', 43.762, 11.248, 'vielleicht')").run(); + // Eine Buchung, die keinen Tag (mehr) hat. + db.prepare("INSERT INTO reservations (trip_id, day_id, type, title, location, reservation_time, status) VALUES (1, NULL, 'flight', 'LH 1234', 'FRA', '2026-06-14T08:00', 'confirmed')").run(); + db.prepare("INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (1, 'Hokkaido', 43.06, 141.35, 'JP', 'im Winter', '2027-02-01')").run(); + db.prepare("INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (2, 'Bobs Traumziel', 1.0, 2.0)").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( @@ -267,6 +277,39 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { expect(JSON.stringify(res.body)).not.toContain('@example.com'); }); + it('returns shortlisted places, which are half of a real trip', async () => { + const res = await get('/api/v1/trips/1?include=places', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.unplanned_places).toEqual([ + expect.objectContaining({ name: 'Boboli-Garten', lat: 43.762, lng: 11.248, notes: 'vielleicht' }), + ]); + // Hotel Alba has no day either, but it is the accommodation and is + // reported there — a shortlist that includes your hotel is not a shortlist. + expect(res.body.unplanned_places.map((p: { name: string }) => p.name)).not.toContain('Hotel Alba'); + // And the scheduled ones still sit on their day, not in both places. + expect(res.body.days[0].places.map((p: { name: string }) => p.name)).toEqual(['Uffizien', 'Ponte Vecchio']); + }); + + it('returns bookings that lost their day instead of dropping them', async () => { + const res = await get('/api/v1/trips/1?include=reservations', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.unscheduled_reservations).toEqual([ + expect.objectContaining({ type: 'flight', title: 'LH 1234', location: 'FRA' }), + ]); + }); + + it('implies days when asked for something that lives on one', async () => { + // The section Evgenii named first. Before days were implied this answered + // the trip summary and nothing else, without saying so. + const res = await get('/api/v1/trips/1?include=notes', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.days).toBeDefined(); + expect(res.body.days[0].day_notes.length).toBeGreaterThan(0); + // Still only what was asked for: no places came along for the ride. + expect(res.body.days[0].places).toEqual([]); + expect(res.body.unplanned_places).toBeUndefined(); + }); + 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); @@ -293,4 +336,27 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { expect((await get('/api/v1/trips/abc', ADA_TOKEN)).status).toBe(400); }); }); + + describe('bucket list', () => { + it("returns the caller's own wishlist, with coordinates", async () => { + const res = await get('/api/v1/bucket-list', ADA_TOKEN); + expect(res.status).toBe(200); + expect(res.body.items).toEqual([ + { name: 'Hokkaido', lat: 43.06, lng: 141.35, country_code: 'JP', notes: 'im Winter', target_date: '2027-02-01' }, + ]); + }); + + it("does not leak another user's wishlist", async () => { + const res = await get('/api/v1/bucket-list', BOB_TOKEN); + expect(res.status).toBe(200); + expect(res.body.items).toEqual([ + { name: 'Bobs Traumziel', lat: 1, lng: 2, country_code: null, notes: null, target_date: null }, + ]); + }); + + it('needs a token like everything else here', async () => { + const res = await request(server).get('/api/v1/bucket-list'); + expect(res.status).toBe(401); + }); + }); }); diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts index 1a80730df..bf4710f94 100644 --- a/server/tests/unit/nest/public-api.controller.test.ts +++ b/server/tests/unit/nest/public-api.controller.test.ts @@ -25,6 +25,15 @@ const TRIP = { updated_at: '2026-06-01 10:00:00', }; +const ITEM = { + name: 'Hokkaido', + lat: 43.06, + lng: 141.35, + country_code: 'JP', + notes: null, + target_date: null, +}; + /** The limiter always allows unless a test says otherwise. */ function makeController(svc: Partial, allow = true) { const rl = { check: vi.fn().mockReturnValue(allow) } as unknown as RateLimitService; @@ -47,6 +56,29 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { } describe('PublicApiController', () => { + describe('GET /api/v1/bucket-list', () => { + it('returns the caller’s wishlist and passes the id from the guard, never the query', () => { + const listBucketList = vi.fn().mockReturnValue([ITEM]); + expect(makeController({ listBucketList }).listBucketList(req(7))).toEqual({ items: [ITEM] }); + expect(listBucketList).toHaveBeenCalledWith(7); + }); + + it('401s when the guard left no user behind', () => { + const listBucketList = vi.fn(); + expect(thrown(() => makeController({ listBucketList }).listBucketList(reqWithoutUser()))).toEqual({ + status: 401, + body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + }); + expect(listBucketList).not.toHaveBeenCalled(); + }); + + it('counts against the same rate budget as everything else', () => { + const listBucketList = vi.fn(); + expect(thrown(() => makeController({ listBucketList }, false).listBucketList(req(7))).status).toBe(429); + expect(listBucketList).not.toHaveBeenCalled(); + }); + }); + describe('GET /api/v1/trips', () => { it('returns the accessible trips for the token owner', () => { const listTrips = vi.fn().mockReturnValue([TRIP]); diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts index 042297d08..a58ebd12d 100644 --- a/server/tests/unit/nest/public-api.guard.test.ts +++ b/server/tests/unit/nest/public-api.guard.test.ts @@ -23,7 +23,7 @@ function contextWith(headers: Record) { } function makeGuard(verify: (raw: string) => User | null) { - return new ApiTokenGuard({ verifyMcpToken: verify } as unknown as TokenService); + return new ApiTokenGuard({ verifyApiToken: verify } as unknown as TokenService); } /** Run the guard, expecting a refusal; return its { status, body }. */ @@ -69,6 +69,15 @@ describe('ApiTokenGuard', () => { expect(verify).not.toHaveBeenCalled(); }); + it('never reaches for the MCP verifier, so the two credentials cannot swap', () => { + const verifyApiToken = vi.fn().mockReturnValue(USER); + const verifyMcpToken = vi.fn(); + const guard = new ApiTokenGuard({ verifyApiToken, verifyMcpToken } as unknown as TokenService); + guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' })); + expect(verifyApiToken).toHaveBeenCalledTimes(1); + expect(verifyMcpToken).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( diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts index fd26fedbf..20bf6af92 100644 --- a/shared/src/public-api/public-api.schema.ts +++ b/shared/src/public-api/public-api.schema.ts @@ -106,6 +106,32 @@ export const publicApiTravellerSchema = z.object({ }); export type PublicApiTraveller = z.infer; +/** + * An entry on the traveller's bucket list: somewhere they want to go, with no + * trip attached yet. + * + * Lives on the user rather than on a trip, which is why it has its own endpoint + * rather than an `include`. It is the one part of TREK that is explicitly about + * places not yet visited, so a consumer that knows where someone actually went + * can tell them they got there. + */ +export const publicApiBucketListItemSchema = z.object({ + name: z.string(), + lat: z.number().nullable(), + lng: z.number().nullable(), + /** ISO 3166-1 alpha-2, when the entry is pinned to a country. */ + country_code: z.string().nullable(), + notes: z.string().nullable(), + /** An optional date the traveller is aiming for. Not a booking. */ + target_date: z.string().nullable(), +}); +export type PublicApiBucketListItem = z.infer; + +export const publicApiBucketListSchema = z.object({ + items: z.array(publicApiBucketListItemSchema), +}); +export type PublicApiBucketList = z.infer; + /** Trip without its itinerary — what the list endpoint returns. */ export const publicApiTripSummarySchema = z.object({ id: z.number(), @@ -136,6 +162,24 @@ export const publicApiTripSchema = publicApiTripSummarySchema.extend({ days: z.array(publicApiDaySchema).optional(), accommodations: z.array(publicApiAccommodationSchema).optional(), travellers: z.array(publicApiTravellerSchema).optional(), + /** + * Places on the trip that sit on no day yet — the shortlist a traveller + * collects before deciding when to go where. + * + * Comes with `include=places`, because a section called "places" that silently + * omitted half of them would be worse than one that did not exist. On a real + * instance these are routinely half of a trip's places, and they carry the + * coordinates that make them worth matching against. + */ + unplanned_places: z.array(publicApiPlaceSchema).optional(), + /** + * Bookings not pinned to a day. A reservation keeps its `day_id` as a nullable + * reference, and deleting a day sets it null rather than deleting the booking, + * so these exist in the wild and are not an edge case. + * + * Comes with `include=reservations`, for the same reason as above. + */ + unscheduled_reservations: z.array(publicApiReservationSchema).optional(), }); export type PublicApiTrip = z.infer; diff --git a/wiki/Public-API.md b/wiki/Public-API.md index 12bdea2de..c348950cf 100644 --- a/wiki/Public-API.md +++ b/wiki/Public-API.md @@ -10,6 +10,7 @@ Read trips. That is deliberate and complete: - list the trips you own or are a member of - fetch one trip with its days, places in planned order, day notes, reservations, accommodations and fellow travellers +- read the bucket list: places the traveller wants to reach, not tied to any trip It cannot write anything. An integration that reads your itinerary needs no ability to delete a place, and a key that can only read is a very different thing to hand to third-party software. @@ -83,6 +84,15 @@ Pick what you need with `?include=`, comma-separated: | `accommodations` | where you sleep, with the date range resolved and check-in/check-out times | | `travellers` | who is on the trip, by display name | +Two sections come back at trip level rather than on a day, because that is where they live: + +| Field | Comes with | What it is | +|---|---|---| +| `unplanned_places` | `places` | places collected but not scheduled yet. On a real instance these are routinely **half** of a trip's places, and they carry coordinates. A hotel is not listed here; it is under `accommodations`. | +| `unscheduled_reservations` | `reservations` | bookings with no day. Deleting a day detaches its bookings rather than deleting them, so these exist in the wild. | + +Asking for `places`, `notes` or `reservations` brings `days` along automatically, since that is where they are reported. `?include=notes` returns the day skeleton with its notes and empty place lists, not an empty trip. + Omitting `include` returns everything. A section you did not ask for is **absent** rather than empty, so your code can tell "not requested" apart from "nothing there". An unknown section name is a `400` rather than being silently ignored — a typo should not hand you a payload quietly missing what you wanted. ```bash @@ -131,12 +141,29 @@ curl -H "Authorization: Bearer trek_…" \ } ``` +### `GET /api/v1/bucket-list` + +Places the caller wants to reach, with no trip attached. Its own endpoint because it hangs off the user, not a trip. + +```json +{ + "items": [ + { "name": "Hokkaido", "lat": 43.06, "lng": 141.35, + "country_code": "JP", "notes": "in winter", "target_date": "2027-02-01" } + ] +} +``` + +`target_date` is an aspiration, not a booking. Entries are returned whether or not the Atlas addon is switched on: the addon governs whether TREK shows the feature, not whether the rows exist, and an endpoint whose answers change when an unrelated toggle moves is one nobody can build on. + ## Notes for integrators **Join on dates, not ids.** Every day carries an ISO `date`, and accommodations carry a resolved date range rather than internal day ids. If your software keeps its own notion of a trip, the date is the one thing both sides can agree on. TREK's internal ids are not in the payload at all, by design — they would tie you to storage details that are free to change. **One request, not many.** Rather than separate endpoints per section, `?include=` lets you fetch exactly what you need in a single call. Fetching a trip four times for four sections would burn your rate budget for no benefit. +**There are no timezones.** Times are stored and returned exactly as the traveller typed them: `14:00` means two in the afternoon wherever they were. If you are matching against timestamps of your own, the trip's dates are the reliable join; the times are a hint, not a UTC instant. + **Rate limit: 120 requests per minute**, counted per user rather than per IP address. A self-hosted integration and its owner's browser often share an address, and an IP-based limit would let one starve the other. Exceeding it returns `429`; back off and retry. **Errors** are JSON with an `error` field: