mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP. Handing the same credential to a third-party integration gives it the whole MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind sits in the WHERE clause — a token of the wrong kind is indistinguishable from one that does not exist. Also adds `travellers` to the trip payload: display name and an owner flag, no ids and no email addresses. An integration key reads its owner's itinerary; it is not a way to enumerate the people around them. Documents the surface in the wiki, since integrators need something to read that is not the source.
This commit is contained in:
@@ -4154,6 +4154,28 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'");
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Separate an integration key from an MCP token (#2089).
|
||||
*
|
||||
* Both live in mcp_tokens and until now both opened everything a token can
|
||||
* open. That was fine while /mcp was the only consumer; with a public REST
|
||||
* surface it means a key somebody minted for a chat client also reads their
|
||||
* trips over HTTP, and a key minted for an integration can drive every MCP
|
||||
* tool. One credential, two very different blast radii.
|
||||
*
|
||||
* `kind` splits them, and every existing row becomes 'mcp' — that is what
|
||||
* they were issued for, and silently widening a key that is already in
|
||||
* somebody's config would be the opposite of what this migration is for.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('mcp_tokens')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'kind')) {
|
||||
db.exec("ALTER TABLE mcp_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'mcp'");
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -321,6 +321,43 @@ export class AuthController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration keys for the public API (`/api/v1`).
|
||||
*
|
||||
* Separate from the MCP tokens above because they open different doors: an MCP
|
||||
* token drives every assistant tool, an API key reads trips over HTTP. Same
|
||||
* table, different `kind`, and each surface verifies only its own — so a key
|
||||
* handed to a third-party integration cannot be replayed against /mcp.
|
||||
*
|
||||
* Unlike MCP tokens these are not forbidden on a managed instance: the surface
|
||||
* they unlock is read-only and scoped to the caller's own trips, which is the
|
||||
* whole reason it exists.
|
||||
*/
|
||||
@Get('api-tokens')
|
||||
listApiTokens(@CurrentUser() user: User) {
|
||||
return { tokens: this.tokens.listApiTokens(user.id) };
|
||||
}
|
||||
|
||||
@Post('api-tokens')
|
||||
@HttpCode(201)
|
||||
createApiToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.tokens.createApiToken(user.id, body.name);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { token: result.token };
|
||||
}
|
||||
|
||||
@Delete('api-tokens/:id')
|
||||
deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const result = this.tokens.deleteApiToken(user.id, id);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('ws-token')
|
||||
@HttpCode(200)
|
||||
wsToken(@CurrentUser() user: User) {
|
||||
|
||||
@@ -5,11 +5,15 @@ 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.
|
||||
* Accepts an **API key** — the kind users mint under Settings → Integrations for
|
||||
* third-party software. An MCP token does not resolve here, and an API key does not
|
||||
* open the MCP surface: the two are separate credentials with separate blast radii,
|
||||
* and `verifyApiToken` puts the kind in the WHERE clause so a token of the wrong
|
||||
* kind is indistinguishable from one that does not exist.
|
||||
*
|
||||
* The lookup hashes the presented key with SHA-256 and matches the hash — the raw
|
||||
* key 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:
|
||||
@@ -38,7 +42,7 @@ export class ApiTokenGuard implements CanActivate {
|
||||
401,
|
||||
);
|
||||
}
|
||||
const user = this.tokens.verifyMcpToken(token);
|
||||
const user = this.tokens.verifyApiToken(token);
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PublicApiInclude,
|
||||
PublicApiPlace,
|
||||
PublicApiReservation,
|
||||
PublicApiTraveller,
|
||||
PublicApiTrip,
|
||||
PublicApiTripSummary,
|
||||
} from '@trek/shared';
|
||||
@@ -75,6 +76,9 @@ export class PublicApiService {
|
||||
if (include.includes('accommodations')) {
|
||||
trip.accommodations = this.buildAccommodations(tripId);
|
||||
}
|
||||
if (include.includes('travellers')) {
|
||||
trip.travellers = this.buildTravellers(tripId);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
@@ -216,6 +220,29 @@ export class PublicApiService {
|
||||
notes: r.notes ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on the trip: the owner first, then members in join order.
|
||||
*
|
||||
* Names only. The query selects `username` and nothing else — no ids, no email
|
||||
* addresses — because an integration key is a credential for reading its owner's
|
||||
* itinerary, not for enumerating the people around them. What it returns is
|
||||
* exactly what those people already see on the trip in TREK.
|
||||
*/
|
||||
private buildTravellers(tripId: number): PublicApiTraveller[] {
|
||||
const rows = this.db.all<TravellerRow>(
|
||||
`SELECT u.username, 1 AS is_owner
|
||||
FROM trips t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.id = ?
|
||||
UNION ALL
|
||||
SELECT u.username, 0 AS is_owner
|
||||
FROM trip_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.trip_id = ?
|
||||
ORDER BY is_owner DESC`,
|
||||
tripId, tripId,
|
||||
);
|
||||
return rows.map((r) => ({ name: r.username, owner: r.is_owner === 1 }));
|
||||
}
|
||||
}
|
||||
|
||||
function toTripSummary(row: TripRow): PublicApiTripSummary {
|
||||
@@ -296,6 +323,11 @@ interface ReservationRow {
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface TravellerRow {
|
||||
username: string;
|
||||
is_owner: number;
|
||||
}
|
||||
|
||||
interface AccommodationRow {
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
|
||||
@@ -8,6 +8,12 @@ import { EphemeralTokenService } from '../auth/ephemeral-token.service';
|
||||
import { revokeUserSessions } from '../../mcp/sessionManager';
|
||||
import { User } from '../../types';
|
||||
|
||||
/**
|
||||
* What a token is allowed to drive. Stored on the row so each surface can accept
|
||||
* only its own: 'mcp' for the assistant tools, 'api' for the public REST surface.
|
||||
*/
|
||||
type TokenKind = 'mcp' | 'api';
|
||||
|
||||
/**
|
||||
* Everything that mints or checks a token that is not the login JWT: the
|
||||
* long-lived MCP tokens a user manages in settings, and the short-lived ws /
|
||||
@@ -33,18 +39,44 @@ export class TokenService {
|
||||
) {}
|
||||
|
||||
listMcpTokens(userId: number) {
|
||||
return this.listTokens(userId, 'mcp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration keys for the public API — a different credential from an MCP
|
||||
* token even though both live in this table.
|
||||
*
|
||||
* They are split because they open different doors: an MCP token drives every
|
||||
* tool the assistant exposes, an API key reads trips over HTTP. Handing a
|
||||
* third-party integration something that can also delete a place is a blast
|
||||
* radius nobody asked for, so `kind` keeps the two apart and each surface
|
||||
* verifies the one it accepts.
|
||||
*/
|
||||
listApiTokens(userId: number) {
|
||||
return this.listTokens(userId, 'api');
|
||||
}
|
||||
|
||||
private listTokens(userId: number, kind: TokenKind) {
|
||||
return this.db.all(
|
||||
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC',
|
||||
userId
|
||||
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
|
||||
userId, kind
|
||||
);
|
||||
}
|
||||
|
||||
createMcpToken(userId: number, rawName: unknown): { error?: string; status?: number; token?: Record<string, unknown> } {
|
||||
createMcpToken(userId: number, rawName: unknown) {
|
||||
return this.createToken(userId, rawName, 'mcp');
|
||||
}
|
||||
|
||||
createApiToken(userId: number, rawName: unknown) {
|
||||
return this.createToken(userId, rawName, 'api');
|
||||
}
|
||||
|
||||
private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record<string, unknown> } {
|
||||
const name = rawName as string | undefined;
|
||||
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
|
||||
|
||||
const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?', userId)!.count;
|
||||
const tokenCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ? AND kind = ?', userId, kind)!.count;
|
||||
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
|
||||
|
||||
const rawToken = 'trek_' + randomBytes(24).toString('hex');
|
||||
@@ -52,8 +84,8 @@ export class TokenService {
|
||||
const tokenPrefix = rawToken.slice(0, 13);
|
||||
|
||||
const result = this.db.run(
|
||||
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)',
|
||||
userId, name.trim(), tokenHash, tokenPrefix
|
||||
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)',
|
||||
userId, name.trim(), tokenHash, tokenPrefix, kind
|
||||
);
|
||||
|
||||
const token = this.db.get(
|
||||
@@ -64,8 +96,21 @@ export class TokenService {
|
||||
return { token: { ...(token as object), raw_token: rawToken } };
|
||||
}
|
||||
|
||||
deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } {
|
||||
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?', tokenId, userId);
|
||||
deleteMcpToken(userId: number, tokenId: string) {
|
||||
return this.deleteToken(userId, tokenId, 'mcp');
|
||||
}
|
||||
|
||||
deleteApiToken(userId: number, tokenId: string) {
|
||||
return this.deleteToken(userId, tokenId, 'api');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped by kind as well as by owner: without it the integrations panel would
|
||||
* happily delete a token the MCP panel manages, and the user would find a key
|
||||
* missing from a screen they never opened.
|
||||
*/
|
||||
private deleteToken(userId: number, tokenId: string, kind: TokenKind): { error?: string; status?: number; success?: boolean } {
|
||||
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?', tokenId, userId, kind);
|
||||
if (!token) return { error: 'Token not found', status: 404 };
|
||||
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId);
|
||||
// Best-effort, like the changePassword/resetPassword revocations: a session
|
||||
@@ -130,13 +175,30 @@ export class TokenService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
verifyMcpToken(rawToken: string): User | null {
|
||||
return this.verifyToken(rawToken, 'mcp');
|
||||
}
|
||||
|
||||
/** Verifies an integration key. An MCP token presented here does not resolve. */
|
||||
verifyApiToken(rawToken: string): User | null {
|
||||
return this.verifyToken(rawToken, 'api');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash, look up, and require the kind the calling surface accepts.
|
||||
*
|
||||
* The kind is part of the WHERE clause rather than checked afterwards, so a
|
||||
* token of the wrong kind is indistinguishable from one that does not exist —
|
||||
* neither the caller nor a timing measurement learns that the string was a
|
||||
* real credential for somewhere else.
|
||||
*/
|
||||
private verifyToken(rawToken: string, kind: TokenKind): User | null {
|
||||
const hash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const row = this.db.get<User>(`
|
||||
SELECT u.id, u.username, u.email, u.role
|
||||
FROM mcp_tokens mt
|
||||
JOIN users u ON mt.user_id = u.id
|
||||
WHERE mt.token_hash = ?
|
||||
`, hash);
|
||||
WHERE mt.token_hash = ? AND mt.kind = ?
|
||||
`, hash, kind);
|
||||
if (row) {
|
||||
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
|
||||
return row;
|
||||
|
||||
@@ -23,7 +23,8 @@ const { db } = vi.hoisted(() => {
|
||||
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);
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME,
|
||||
kind TEXT NOT NULL DEFAULT 'mcp');
|
||||
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,
|
||||
@@ -74,18 +75,21 @@ 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(
|
||||
function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): string {
|
||||
db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)').run(
|
||||
userId,
|
||||
'test',
|
||||
createHash('sha256').update(raw).digest('hex'),
|
||||
raw.slice(0, 13),
|
||||
kind,
|
||||
);
|
||||
return raw;
|
||||
}
|
||||
|
||||
const ADA_TOKEN = 'trek_' + 'a'.repeat(48);
|
||||
const BOB_TOKEN = 'trek_' + 'b'.repeat(48);
|
||||
/** A valid credential for /mcp — must not open this surface. */
|
||||
const MCP_TOKEN = 'trek_' + 'c'.repeat(48);
|
||||
|
||||
describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
let server: Server;
|
||||
@@ -104,6 +108,7 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
db.prepare("INSERT INTO users (id, username, email) VALUES (2, 'bob', 'bob@example.com')").run();
|
||||
seedToken(1, ADA_TOKEN);
|
||||
seedToken(2, BOB_TOKEN);
|
||||
seedToken(1, MCP_TOKEN, 'mcp');
|
||||
|
||||
// Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member.
|
||||
db.prepare(
|
||||
@@ -156,6 +161,12 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
expect((await get('/api/v1/trips', 'trek_' + 'z'.repeat(48))).status).toBe(401);
|
||||
});
|
||||
|
||||
it('refuses a valid MCP token — different kind, different door', async () => {
|
||||
const res = await get('/api/v1/trips', MCP_TOKEN);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -246,6 +257,16 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists travellers by name, owner first, without ids or emails', async () => {
|
||||
const res = await get('/api/v1/trips/3?include=travellers', ADA_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.travellers).toEqual([
|
||||
{ name: 'bob', owner: true },
|
||||
{ name: 'ada', owner: false },
|
||||
]);
|
||||
expect(JSON.stringify(res.body)).not.toContain('@example.com');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -92,6 +92,20 @@ export const publicApiDaySchema = z.object({
|
||||
});
|
||||
export type PublicApiDay = z.infer<typeof publicApiDaySchema>;
|
||||
|
||||
/**
|
||||
* Someone who is on the trip.
|
||||
*
|
||||
* Display name only. Ids would let a consumer correlate the same person across
|
||||
* instances, and email addresses have no business leaving TREK over an
|
||||
* integration key — the name is what a traveller already sees on the trip.
|
||||
*/
|
||||
export const publicApiTravellerSchema = z.object({
|
||||
name: z.string(),
|
||||
/** True for the trip's owner; everyone else joined as a member. */
|
||||
owner: z.boolean(),
|
||||
});
|
||||
export type PublicApiTraveller = z.infer<typeof publicApiTravellerSchema>;
|
||||
|
||||
/** Trip without its itinerary — what the list endpoint returns. */
|
||||
export const publicApiTripSummarySchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -121,6 +135,7 @@ export type PublicApiTripSummary = z.infer<typeof publicApiTripSummarySchema>;
|
||||
export const publicApiTripSchema = publicApiTripSummarySchema.extend({
|
||||
days: z.array(publicApiDaySchema).optional(),
|
||||
accommodations: z.array(publicApiAccommodationSchema).optional(),
|
||||
travellers: z.array(publicApiTravellerSchema).optional(),
|
||||
});
|
||||
export type PublicApiTrip = z.infer<typeof publicApiTripSchema>;
|
||||
|
||||
@@ -129,7 +144,7 @@ export type PublicApiTrip = z.infer<typeof publicApiTripSchema>;
|
||||
* 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 const PUBLIC_API_INCLUDES = ['days', 'places', 'notes', 'reservations', 'accommodations', 'travellers'] as const;
|
||||
export type PublicApiInclude = (typeof PUBLIC_API_INCLUDES)[number];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Public API
|
||||
|
||||
A small, versioned, read-only surface that lets other software read your trips — built for integrations like location trackers, journalling tools and home automation, where something outside TREK wants to know what you planned.
|
||||
|
||||
> **Not the same as the internal API.** TREK's own frontend talks to a few hundred endpoints under `/api/…`. Those answer a session cookie, carry no version and change whenever the client changes — they are not a contract, and anything built on them will break. The public API is the opposite promise: a handful of endpoints under `/api/v1/`, authenticated with a key you mint yourself, that will not change shape without a new version number.
|
||||
|
||||
## What it can do
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## Getting a key
|
||||
|
||||
1. Open **Settings → Integrations**
|
||||
2. Under **API Keys**, click **Create key** and give it a name you will recognise later (the name is only for you — "Dawarich", "home assistant", "laptop script")
|
||||
3. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one.
|
||||
|
||||
Keys look like `trek_` followed by 48 characters. You can hold ten at a time.
|
||||
|
||||
> **API keys and MCP tokens are not interchangeable.** They live on the same screen but open different doors: an [MCP token](MCP-Setup) drives every assistant tool, an API key reads trips over HTTP. A key of the wrong kind is rejected exactly like one that does not exist. Mint the kind the other software asks for.
|
||||
|
||||
To revoke access, delete the key. It stops working immediately, and anything using it gets a 401 on its next request.
|
||||
|
||||
## Using it
|
||||
|
||||
Send the key as a bearer token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer trek_your_key_here" \
|
||||
https://trek.example.com/api/v1/trips
|
||||
```
|
||||
|
||||
or, if the software you are configuring expects a header named for an API key:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: trek_your_key_here" \
|
||||
https://trek.example.com/api/v1/trips
|
||||
```
|
||||
|
||||
Both are equivalent. Use whichever the other side offers.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/v1/trips`
|
||||
|
||||
Every trip you can reach, newest first, without itineraries.
|
||||
|
||||
```json
|
||||
{
|
||||
"trips": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Tuscany",
|
||||
"description": "Wine and hill towns",
|
||||
"start_date": "2026-06-14",
|
||||
"end_date": "2026-06-22",
|
||||
"currency": "EUR",
|
||||
"archived": false,
|
||||
"updated_at": "2026-06-01 10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`updated_at` moves when the trip's **own** fields change — title, dates, currency, cover. It does **not** move when a place, a day or a note is edited. Do not use it to decide whether to re-fetch; you would miss every itinerary change. Compare the payload instead.
|
||||
|
||||
### `GET /api/v1/trips/{id}`
|
||||
|
||||
One trip with its contents. Returns **404** if the trip does not exist *or* is not yours — the two are indistinguishable on purpose, so the endpoint cannot be used to find out which trip ids exist.
|
||||
|
||||
Pick what you need with `?include=`, comma-separated:
|
||||
|
||||
| Section | What it adds |
|
||||
|---|---|
|
||||
| `days` | the itinerary spine: one entry per day, with `date`, `day_number`, `title`, `notes` |
|
||||
| `places` | stops on each day, **in planned order**, with coordinates, times, duration, category and transport mode |
|
||||
| `notes` | timed free-text notes on a day |
|
||||
| `reservations` | bookings on their starting day: type, title, location, time, status |
|
||||
| `accommodations` | where you sleep, with the date range resolved and check-in/check-out times |
|
||||
| `travellers` | who is on the trip, by display name |
|
||||
|
||||
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
|
||||
# only the notes, for a lightweight sync
|
||||
curl -H "Authorization: Bearer trek_…" \
|
||||
"https://trek.example.com/api/v1/trips/12?include=days,notes"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Tuscany",
|
||||
"start_date": "2026-06-14",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"day_number": 1,
|
||||
"title": "Arrival",
|
||||
"notes": null,
|
||||
"places": [
|
||||
{
|
||||
"name": "Uffizi",
|
||||
"address": "Florence",
|
||||
"lat": 43.76, "lng": 11.25,
|
||||
"time": "14:00", "end_time": null,
|
||||
"duration_minutes": 180,
|
||||
"category": "Museum",
|
||||
"notes": null,
|
||||
"transport_mode": "walking"
|
||||
}
|
||||
],
|
||||
"day_notes": [{ "text": "Bring the tickets", "time": "09:00" }],
|
||||
"reservations": [
|
||||
{ "type": "flight", "title": "LH 1234", "location": "FRA",
|
||||
"time": "2026-06-14T08:00", "end_time": null,
|
||||
"status": "confirmed", "notes": null }
|
||||
]
|
||||
}
|
||||
],
|
||||
"accommodations": [
|
||||
{ "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 }
|
||||
],
|
||||
"travellers": [{ "name": "ada", "owner": true }, { "name": "bob", "owner": false }]
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
**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:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `400` | malformed trip id, or an unknown `include` section |
|
||||
| `401` | missing, malformed or unknown key — also what you get for a key of the wrong kind |
|
||||
| `404` | no such trip, or not one of yours |
|
||||
| `429` | rate limit exceeded |
|
||||
|
||||
**Versioning.** `/api/v1` may gain fields; it will not lose them or change their types. A breaking change ships as `/api/v2` and both run side by side for a transition period. Write your client to ignore fields it does not know.
|
||||
|
||||
## What is not here yet
|
||||
|
||||
- **Writing.** Read-only for now. Write access needs per-scope enforcement that this surface does not implement, and a key that only reads is a much safer thing to hand out.
|
||||
- **Incremental sync.** There is no `?since=` filter, because TREK cannot yet answer it truthfully — child records carry no modification timestamp, so a filter on `updated_at` would silently hide trips whose itinerary changed. Fetch the list and compare.
|
||||
- **Webhooks.** Nothing pushes; poll at a sensible interval.
|
||||
|
||||
If you are building something and one of these blocks you, open a discussion — the surface is meant to grow around real integrations rather than ahead of them.
|
||||
|
||||
## See also
|
||||
|
||||
- [MCP Overview](MCP-Overview) — the other machine-facing surface, for AI assistants
|
||||
- [Calendar Feeds](Calendar-Feeds) — subscribe a calendar app to a trip
|
||||
- [Admin: MCP Tokens](Admin-MCP-Tokens) — the instance-wide view of issued tokens
|
||||
@@ -78,6 +78,9 @@
|
||||
- [[Plugin Permissions|Plugin-Permissions]]
|
||||
- [[Publishing a Plugin|Plugin-Publishing]]
|
||||
|
||||
## Integrations
|
||||
- [[Public API|Public-API]]
|
||||
|
||||
## AI / MCP
|
||||
- [[MCP Overview|MCP-Overview]]
|
||||
- [[MCP Setup|MCP-Setup]]
|
||||
|
||||
Reference in New Issue
Block a user