diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 75befcd4e..f65199644 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index b543de0a2..0e1967f46 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -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) { diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts index a413ce0a8..3e2d7e642 100644 --- a/server/src/nest/public-api/api-token.guard.ts +++ b/server/src/nest/public-api/api-token.guard.ts @@ -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' }, diff --git a/server/src/nest/public-api/public-api.service.ts b/server/src/nest/public-api/public-api.service.ts index fda24ca25..7527aebce 100644 --- a/server/src/nest/public-api/public-api.service.ts +++ b/server/src/nest/public-api/public-api.service.ts @@ -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( + `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; diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts index bb028f265..a5df4ca80 100644 --- a/server/src/nest/tokens/token.service.ts +++ b/server/src/nest/tokens/token.service.ts @@ -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 } { + 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 } { 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(` 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; diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts index 09c2296f8..61893327c 100644 --- a/server/tests/e2e/public-api.e2e.test.ts +++ b/server/tests/e2e/public-api.e2e.test.ts @@ -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); diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts index 99ab8dd0c..fd26fedbf 100644 --- a/shared/src/public-api/public-api.schema.ts +++ b/shared/src/public-api/public-api.schema.ts @@ -92,6 +92,20 @@ export const publicApiDaySchema = z.object({ }); export type PublicApiDay = z.infer; +/** + * 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; + /** 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; 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; @@ -129,7 +144,7 @@ export type PublicApiTrip = z.infer; * 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]; /** diff --git a/wiki/Public-API.md b/wiki/Public-API.md new file mode 100644 index 000000000..12bdea2de --- /dev/null +++ b/wiki/Public-API.md @@ -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 diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index 332544376..5b8758c9a 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -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]]