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:
Maurice
2026-08-28 01:29:04 +02:00
committed by Maurice
parent 9cddf683a5
commit 4b120d39c8
9 changed files with 381 additions and 20 deletions
+22
View File
@@ -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) {
+37
View File
@@ -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) {
+10 -6
View File
@@ -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;
+72 -10
View File
@@ -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;
+24 -3
View File
@@ -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);