mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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);
|
||||
|
||||
Reference in New Issue
Block a user