feat(api): let public API keys be scoped to single areas

A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.

Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
This commit is contained in:
Maurice
2026-09-12 23:34:42 +02:00
parent 906eef4349
commit dad1316172
35 changed files with 1657 additions and 35 deletions
+14 -9
View File
@@ -21,13 +21,17 @@ import { TokenService } from '../tokens/token.service';
* - **No session JWT.** A session cookie leaking into a third-party integration is
* exactly what a machine credential exists to prevent, and a JWT that reaches
* this surface is almost always an accident.
* - **No OAuth tokens (yet).** They carry scopes this surface does not interpret
* yet, and accepting a credential whose restrictions you ignore is worse than
* refusing it. When `/api/v1` grows write routes, scopes get honoured first.
* - **No OAuth tokens.** They carry MCP scopes, which are a different vocabulary
* from the read sections this surface understands — `trips:write` has no
* meaning on a read-only API, and quietly mapping one onto the other would be
* inventing permissions nobody granted. Accepting a credential whose
* restrictions you reinterpret is worse than refusing it.
*
* The guard authenticates but does not authorise: it resolves `req.user` and stops
* there. Which trips that user may read is decided per row against
* `DatabaseService.canAccessTrip`, never from anything the caller sent.
* The guard resolves two things and then stops: **who** is calling (`req.user`)
* and **what their key may read** (`req.apiToken`). Both are needed and neither
* is sufficient. Which trips that user may reach is still decided per row
* against `DatabaseService.canAccessTrip`, never from anything the caller sent;
* the grant only narrows that further, and can never widen it.
*/
@Injectable()
export class ApiTokenGuard implements CanActivate {
@@ -42,14 +46,15 @@ export class ApiTokenGuard implements CanActivate {
401,
);
}
const user = this.tokens.verifyApiToken(token);
if (!user) {
const resolved = this.tokens.verifyApiTokenWithGrant(token);
if (!resolved) {
throw new HttpException(
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
401,
);
}
req.user = user;
req.user = resolved.user;
req.apiToken = resolved.grant;
return true;
}
}
@@ -1,5 +1,6 @@
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared';
import type { RateLimitService } from '../common/rate-limit.service';
/**
@@ -44,3 +45,57 @@ export function requireUserId(req: Request): number {
}
return id;
}
/**
* What the key on this request may read.
*
* A request that somehow reached a route without the guard has no grant, and
* the honest answer there is "nothing" rather than "everything" — the same
* fail-closed reasoning as `requireUserId` throwing a 401. A key with no
* narrowing has already been resolved to the full list by the token service,
* so there is no absent-means-all rule left to get wrong here.
*/
export function grantedScopes(req: Request): readonly PublicApiScope[] {
return req.apiToken?.scopes ?? [];
}
/**
* Refuse a request whose key does not cover the section it asked for.
*
* **403, and a code of its own.** Not 401: the credential is valid and
* re-authenticating will not help. Not 404: an integrator debugging a key they
* narrowed themselves needs to be told that, not sent looking for a missing
* trip. The two existing codes are deliberately left alone so
* "expired/invalid" and "not enough access" stay distinguishable.
*/
export function requireScope(req: Request, scope: PublicApiScope): void {
if (!grantedScopes(req).includes(scope)) {
throw new HttpException(
{
error: `This API key is not allowed to read ${scope}`,
code: 'API_SCOPE_FORBIDDEN',
required_scope: scope,
},
403,
);
}
}
/**
* Narrow a set of requested sections to what the key may actually read.
*
* Filtering rather than refusing, for one specific case: `include` defaults to
* everything, so a narrow key asking for a trip with no `include` at all would
* otherwise be refused for wanting sections it never named. It gets what it may
* have. A caller that *names* a forbidden section is a different matter and is
* refused by `requireScope` at the controller, because silently dropping what
* somebody explicitly asked for is how integrators end up debugging their own
* correct code.
*/
export function narrowToGrant<T extends string>(sections: readonly T[], req: Request): T[] {
const granted = new Set<string>(grantedScopes(req));
return sections.filter((section) => granted.has(section));
}
/** Every section, for the callers that need the canonical list. */
export const ALL_PUBLIC_API_SCOPES: readonly PublicApiScope[] = PUBLIC_API_SCOPES;
@@ -10,7 +10,12 @@ import {
} from '@trek/shared';
import { ApiTokenGuard } from './api-token.guard';
import { PublicApiService } from './public-api.service';
import { enforcePublicApiRateLimit, requireUserId } from './public-api-request';
import {
enforcePublicApiRateLimit,
narrowToGrant,
requireScope,
requireUserId,
} from './public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
@@ -48,6 +53,7 @@ export class PublicApiController {
@Get('trips')
listTrips(@Req() req: Request): PublicApiTripList {
this.limit(req);
requireScope(req, 'trips');
return { trips: this.api.listTrips(requireUserId(req)) };
}
@@ -61,6 +67,7 @@ export class PublicApiController {
@Get('bucket-list')
listBucketList(@Req() req: Request): PublicApiBucketList {
this.limit(req);
requireScope(req, 'bucket-list');
return { items: this.api.listBucketList(requireUserId(req)) };
}
@@ -78,8 +85,20 @@ export class PublicApiController {
@Query('include') include?: string,
): PublicApiTrip {
this.limit(req);
requireScope(req, 'trips');
const tripId = parseTripId(id);
const trip = this.api.getTrip(tripId, requireUserId(req), parseInclude(include));
// A named section the key may not read is refused outright; an unnamed one —
// `include` absent, which means "everything" — is simply narrowed. Silently
// dropping a section somebody asked for by name is how an integrator ends
// up debugging their own correct code.
const asked = parseInclude(include);
if (include !== undefined && include.trim() !== '') {
for (const section of asked) requireScope(req, section);
}
const allowed = narrowToGrant(asked, req);
const trip = this.api.getTrip(tripId, requireUserId(req), allowed);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
+123 -8
View File
@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
import {
PUBLIC_API_SCOPES,
type PublicApiGrant,
type PublicApiScope,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
@@ -57,21 +62,46 @@ export class TokenService {
}
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 = ? AND kind = ? ORDER BY created_at DESC',
const rows = this.db.all<TokenRow>(
'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
);
// The MCP list keeps the exact shape it has always had: those tokens carry
// no read scopes, and two columns that are always "everything" would be
// noise in a panel that cannot act on them.
if (kind === 'mcp') {
return rows.map(({ scope_mode: _mode, api_scopes: _scopes, ...rest }) => rest);
}
return rows.map((row) => {
const grant = resolveGrant(row.scope_mode, row.api_scopes);
return {
id: row.id,
name: row.name,
token_prefix: row.token_prefix,
created_at: row.created_at,
last_used_at: row.last_used_at,
scope_mode: grant.mode,
scopes: grant.scopes,
};
});
}
createMcpToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'mcp');
}
createApiToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'api');
/**
* An integration key, optionally narrowed to a few sections.
*
* Omitting `scopes` means the key reads everything — what every key did
* before this argument existed. Narrowing is opt-in on purpose: shipping the
* column must not change what an already-running integration can do.
*/
createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) {
return this.createToken(userId, rawName, 'api', scopes);
}
private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record<string, unknown> } {
private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { 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 };
@@ -83,9 +113,15 @@ export class TokenService {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const tokenPrefix = rawToken.slice(0, 13);
// An MCP token never carries read scopes, so it is stored as "everything" —
// the column default — rather than being handed a list it would ignore.
const narrowed = kind === 'api' ? sanitizeScopes(scopes) : null;
const result = this.db.run(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
userId, name.trim(), tokenHash, tokenPrefix, kind,
narrowed ? 'limited' : 'all',
narrowed ? JSON.stringify(narrowed) : null,
);
const token = this.db.get(
@@ -93,7 +129,10 @@ export class TokenService {
result.lastInsertRowid
);
return { token: { ...(token as object), raw_token: rawToken } };
const grant = kind === 'api'
? { scope_mode: narrowed ? 'limited' : 'all', scopes: narrowed ?? [...PUBLIC_API_SCOPES] }
: {};
return { token: { ...(token as object), ...grant, raw_token: rawToken } };
}
deleteMcpToken(userId: number, tokenId: string) {
@@ -183,6 +222,27 @@ export class TokenService {
return this.verifyToken(rawToken, 'api');
}
/**
* The same lookup, plus what the key is allowed to read.
*
* A second method rather than a widened `verifyApiToken`: the guard needs
* both halves, nothing else needs either, and the existing signature is
* pinned by tests that assert exactly a `User`.
*/
verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User & { scope_mode: string | null; api_scopes: string | null }>(`
SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ? AND mt.kind = 'api'
`, hash);
if (!row) return null;
this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
const { scope_mode, api_scopes, ...user } = row;
return { user: user as User, grant: resolveGrant(scope_mode, api_scopes) };
}
/**
* Hash, look up, and require the kind the calling surface accepts.
*
@@ -206,3 +266,58 @@ export class TokenService {
return null;
}
}
interface TokenRow {
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
scope_mode: string | null;
api_scopes: string | null;
}
/**
* The requested sections, cleaned up — unknown values dropped, duplicates
* collapsed, order pinned to the canonical list so two keys with the same
* access read the same in the panel.
*
* Returns null for "no narrowing was asked for", which is what makes the caller
* store full access rather than an empty list that would lock the key out of
* everything.
*/
function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[] | null {
if (!scopes || scopes.length === 0) return null;
const wanted = new Set(scopes);
const kept = PUBLIC_API_SCOPES.filter((scope) => wanted.has(scope));
return kept.length > 0 ? [...kept] : null;
}
/**
* What a stored row means, read defensively — in both directions.
*
* A row that does not say 'limited' is a full-access key: that is what every key
* written before this column existed had, and a missing narrowing must not cost
* a running integration its access. Narrowing is a deliberate act; its absence
* is not a denial.
*
* But once a row HAS said 'limited', every way of failing to read its list
* denies. Unparseable JSON, a non-array, or a list of scope names this version
* no longer knows (a renamed constant, a hand-edited row) resolves to no scopes
* at all rather than to everything: the whole reason `scope_mode` is an explicit
* flag instead of "NULL means everything" is that a key minted as restricted
* must never widen on its own. A key that stops working is a support ticket; a
* key that quietly reads every trip is the bug this feature exists to prevent.
*/
function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant {
if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
let parsed: unknown;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = null;
}
if (!Array.isArray(parsed)) return { mode: 'limited', scopes: [] };
const kept = sanitizeScopes(parsed.filter((value): value is string => typeof value === 'string'));
return { mode: 'limited', scopes: kept ?? [] };
}
+229 -2
View File
@@ -21,10 +21,15 @@ const { db } = vi.hoisted(() => {
tmp.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);
-- scope_mode/api_scopes are spelled exactly as the scopes migration adds
-- them (NOT NULL DEFAULT 'all', and a nullable JSON list): a row here has to
-- be able to be the row the guard reads in production, or the scope cases
-- below would be passing against a table that does not exist anywhere else.
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,
kind TEXT NOT NULL DEFAULT 'mcp');
kind TEXT NOT NULL DEFAULT 'mcp',
scope_mode TEXT NOT NULL DEFAULT 'all', api_scopes TEXT);
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,
@@ -76,6 +81,10 @@ vi.mock('../../src/db/database', async (importActual) => {
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RateLimitModule } from '../../src/nest/common/rate-limit.module';
import { PublicApiModule } from '../../src/nest/public-api/public-api.module';
import { ApiTokenGuard } from '../../src/nest/public-api/api-token.guard';
import { TokensModule } from '../../src/nest/tokens/tokens.module';
import { PublicStatsController } from '../../src/nest/atlas/public-stats.controller';
import { AtlasService } from '../../src/nest/atlas/atlas.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
/** Mints a token the way TokenService does, so the guard's hash lookup is real. */
@@ -90,17 +99,67 @@ function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): st
return raw;
}
/**
* The same mint, narrowed. `rawScopes` goes into the column verbatim — including
* the deliberately unparseable value one case needs, which is why this takes a
* string rather than an array.
*/
function seedLimitedToken(userId: number, raw: string, rawScopes: string): string {
db.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(
userId,
'test-limited',
createHash('sha256').update(raw).digest('hex'),
raw.slice(0, 13),
'api',
'limited',
rawScopes,
);
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);
/** Narrowed keys, all Ada's, so only the grant differs between them. */
const TRIPS_ONLY_TOKEN = 'trek_' + 'd'.repeat(48);
const TRIPS_DAYS_TOKEN = 'trek_' + 'e'.repeat(48);
/** api_scopes that cannot be parsed — the fall-back-to-everything case. */
const BROKEN_SCOPES_TOKEN = 'trek_' + 'f'.repeat(48);
/** Stubbed so /api/v1/stats can be mounted without AtlasModule's whole graph. */
const STATS = {
getTravelStats: () => ({
countries: ['JP', 'IT'],
cities: ['tokyo'],
coords: [],
totalTrips: 2,
totalDays: 3,
totalPlaces: 4,
totalDistanceKm: 1234,
}),
lastTrip: () => null,
};
describe('Public API v1 e2e (real guard + real SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RateLimitModule, PublicApiModule] }).compile();
const moduleRef = await Test.createTestingModule({
imports: [DatabaseModule, RateLimitModule, TokensModule, PublicApiModule],
// `/api/v1/stats` lives in atlas/ because its figures do, but it is guarded
// and scoped by this directory's code — so it is mounted here with the real
// guard and a stubbed AtlasService. Importing AtlasModule instead would pull
// AuthModule and the storage registry, which reads app_settings on init and
// would need half the schema this file deliberately does not carry. What is
// under test is the refusal, not the arithmetic (public-stats.controller.test
// owns that), and the refusal happens before AtlasService is ever touched.
controllers: [PublicStatsController],
providers: [ApiTokenGuard, { provide: AtlasService, useValue: STATS }],
}).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
@@ -113,6 +172,13 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
seedToken(1, ADA_TOKEN);
seedToken(2, BOB_TOKEN);
seedToken(1, MCP_TOKEN, 'mcp');
// ADA_TOKEN above is seeded without ever naming the two scope columns — the
// row a key minted before the feature existed leaves behind. It is the
// backwards-compatibility case and every existing test in this file rides on
// it, so if narrowing ever leaks into the default those tests fail first.
seedLimitedToken(1, TRIPS_ONLY_TOKEN, JSON.stringify(['trips']));
seedLimitedToken(1, TRIPS_DAYS_TOKEN, JSON.stringify(['trips', 'days', 'notes']));
seedLimitedToken(1, BROKEN_SCOPES_TOKEN, '{"not":"a list"');
// Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member.
db.prepare(
@@ -359,4 +425,165 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
expect(res.status).toBe(401);
});
});
/**
* Read scopes (#2279).
*
* The question a mock cannot answer: does a key stored in the database a
* particular way actually reach the sections it should, and stop at the ones it
* should not? Every case below starts from a real row and ends at a real HTTP
* status, because the two halves — what the column says and what the route does
* — are written in different files and have no reason to agree on their own.
*
* The first case is the important one. Narrowing is a feature; not breaking the
* keys that already exist is a promise.
*/
describe('read scopes', () => {
it('PUBAPI-SCOPE-001: a key minted before scopes existed is stored as a full grant', () => {
const row = db
.prepare('SELECT scope_mode, api_scopes FROM mcp_tokens WHERE token_prefix = ?')
.get(ADA_TOKEN.slice(0, 13)) as { scope_mode: string; api_scopes: string | null };
// The two columns were never named at insert time — exactly the row the
// ALTER leaves behind for a key that already existed.
expect(row).toEqual({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-002: and still reads every section, which is the whole promise', async () => {
const [trips, bucket, stats, trip] = await Promise.all([
get('/api/v1/trips', ADA_TOKEN),
get('/api/v1/bucket-list', ADA_TOKEN),
get('/api/v1/stats', ADA_TOKEN),
get('/api/v1/trips/1', ADA_TOKEN),
]);
expect([trips.status, bucket.status, stats.status, trip.status]).toEqual([200, 200, 200, 200]);
expect(stats.body).toMatchObject({ total_trips: 2, total_countries: 2 });
// The full payload, not a narrowed one: every section is there.
expect(trip.body.days).toHaveLength(2);
expect(trip.body.accommodations).toBeDefined();
expect(trip.body.travellers).toBeDefined();
expect(trip.body.unplanned_places).toBeDefined();
expect(trip.body.unscheduled_reservations).toBeDefined();
});
it('PUBAPI-SCOPE-003: a key narrowed to trips reads trips', async () => {
const res = await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(200);
expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([1, 3]);
});
it('PUBAPI-SCOPE-004: and is refused the bucket list, with a code of its own', async () => {
const res = await get('/api/v1/bucket-list', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read bucket-list',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'bucket-list',
});
// And nothing of the withheld list leaked out through the refusal.
expect(JSON.stringify(res.body)).not.toContain('Hokkaido');
});
it('PUBAPI-SCOPE-005: and refused the stats route, the widest answer on the surface', async () => {
const res = await get('/api/v1/stats', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read stats',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'stats',
});
});
/**
* A valid credential that may not read something is not an authentication
* problem, and the two have to stay distinguishable: re-minting a key fixes a
* 401 and does nothing at all for a 403.
*/
it('PUBAPI-SCOPE-006: the refusal is a 403 and leaves both 401 bodies exactly as they were', async () => {
const [none, unknown, wrongKind, noneOnStats] = await Promise.all([
get('/api/v1/trips'),
get('/api/v1/trips', 'trek_' + 'z'.repeat(48)),
get('/api/v1/trips', MCP_TOKEN),
get('/api/v1/stats'),
]);
expect(none.status).toBe(401);
expect(none.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
expect(unknown.status).toBe(401);
expect(unknown.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
expect(wrongKind.status).toBe(401);
expect(wrongKind.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
expect(noneOnStats.status).toBe(401);
expect(noneOnStats.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
});
it('PUBAPI-SCOPE-007: a trip asked for without include carries only the granted sections', async () => {
const res = await get('/api/v1/trips/1', TRIPS_ONLY_TOKEN);
// Not a refusal: `include` absent means "everything", and a key that never
// named a forbidden section gets what it may have.
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 1, title: 'Toskana' });
expect(res.body.days).toBeUndefined();
expect(res.body.accommodations).toBeUndefined();
expect(res.body.travellers).toBeUndefined();
expect(res.body.unplanned_places).toBeUndefined();
expect(res.body.unscheduled_reservations).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('Uffizien');
});
it('PUBAPI-SCOPE-008: a wider grant fills in exactly the sections it covers', async () => {
const res = await get('/api/v1/trips/1', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(200);
expect(res.body.days).toHaveLength(2);
expect(res.body.days[0].day_notes).toEqual([{ text: 'Tickets mitnehmen', time: '09:00' }]);
// notes was granted, places was not — and days is not a back door to them.
expect(res.body.days[0].places).toEqual([]);
expect(res.body.accommodations).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain('Uffizien');
});
it('PUBAPI-SCOPE-009: an include the key does cover still answers', async () => {
const res = await get('/api/v1/trips/1?include=days,notes', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(200);
expect(res.body.days[0].day_notes).toHaveLength(1);
});
it('PUBAPI-SCOPE-010: naming a forbidden section is refused rather than silently dropped', async () => {
const res = await get('/api/v1/trips/1?include=days,places', TRIPS_DAYS_TOKEN);
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'This API key is not allowed to read places',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'places',
});
});
it('PUBAPI-SCOPE-011: a key that says it is narrowed and cannot say how reads nothing', async () => {
// The direction matters. A key that never said 'limited' keeps the full
// access it was minted with; one that HAS said it is restricted must not
// widen because its list became unreadable — a hand-edited row, or a scope
// constant a later version renamed away. Failing closed turns that into a
// support ticket instead of a key quietly reading every trip.
const [bucket, trip] = await Promise.all([
get('/api/v1/bucket-list', BROKEN_SCOPES_TOKEN),
get('/api/v1/trips/1', BROKEN_SCOPES_TOKEN),
]);
expect(bucket.status).toBe(403);
expect(bucket.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN' });
expect(trip.status).toBe(403);
});
it('PUBAPI-SCOPE-012: a scope never widens access — the owner check still decides', async () => {
// 'trips' granted, Bob's trip asked for: a grant narrows what a key may read
// and can never hand it a row its owner cannot reach.
const res = await get('/api/v1/trips/2', TRIPS_ONLY_TOKEN);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('PUBAPI-SCOPE-013: a narrowed key is stamped as used like any other', async () => {
await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
const row = db
.prepare('SELECT last_used_at FROM mcp_tokens WHERE token_prefix = ?')
.get(TRIPS_ONLY_TOKEN.slice(0, 13)) as { last_used_at: string | null };
expect(row.last_used_at).not.toBeNull();
});
});
});
@@ -0,0 +1,681 @@
/**
* Per-key read scopes for /api/v1 (#2279).
*
* The feature is spread over four files that each know one third of it: the
* shared vocabulary, the column and how it is read back (TokenService), the
* grant on the request (ApiTokenGuard), and the refusal (public-api-request,
* used from two controllers in two modules). Nothing forces those halves to
* agree, so the cases below walk the whole path — a row in, a status out — and
* pin the one rule that is easy to get backwards on the way:
*
* a key that was never narrowed reads everything.
*
* That is not a nicety. Every integration key minted before this shipped has an
* empty `api_scopes`, and the day "no list" starts meaning "no access" is the
* day every one of them stops working.
*
* The end-to-end shape (real HTTP, real hash lookup) lives in
* tests/e2e/public-api.e2e.test.ts; this file owns the branches that are
* awkward to reach through a socket — a NULL column, a key the guard never
* touched, an unknown scope name.
*/
// ---------------------------------------------------------------------------
// vi.hoisted: real in-memory DB + the module mock, before any import
// ---------------------------------------------------------------------------
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip: () => undefined,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Imports (after the mocks)
// ---------------------------------------------------------------------------
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import {
PUBLIC_API_INCLUDES,
PUBLIC_API_SCOPES,
apiTokenCreateRequestSchema,
mcpTokenCreateRequestSchema,
type PublicApiGrant,
type PublicApiScope,
} from '@trek/shared';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TokenService } from '../../../src/nest/tokens/token.service';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
import type { PublicApiService } from '../../../src/nest/public-api/public-api.service';
import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import {
grantedScopes,
narrowToGrant,
requireScope,
} from '../../../src/nest/public-api/public-api-request';
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import { AuthController } from '../../../src/nest/auth/auth.controller';
import type { AuthService } from '../../../src/nest/auth/auth.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { UserProfileService } from '../../../src/nest/auth/user-profile.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ALL: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
const limited = (...scopes: PublicApiScope[]): PublicApiGrant => ({ mode: 'limited', scopes });
/** A request as the guard leaves it: a user and a grant, nothing else. */
const req = (grant: PublicApiGrant | undefined = ALL, userId: number | null = 7) =>
({
headers: {},
user: userId === null ? undefined : ({ id: userId } as User),
apiToken: grant,
}) as Request;
const TRIP = {
id: 12,
title: 'Toskana',
description: null,
start_date: '2026-06-14',
end_date: '2026-06-22',
currency: 'EUR',
archived: false,
updated_at: '2026-06-01 10:00:00',
};
function thrown(fn: () => unknown): { status: number; body: unknown } {
try {
fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to refuse');
}
function apiController(svc: Partial<PublicApiService>) {
const rl = { check: vi.fn().mockReturnValue(true) } as unknown as RateLimitService;
return new PublicApiController(svc as PublicApiService, rl);
}
function statsController() {
return new PublicStatsController(
{
getTravelStats: vi.fn(() => ({
countries: ['JP'], cities: ['tokyo'], coords: [],
totalTrips: 1, totalDays: 2, totalPlaces: 3, totalDistanceKm: 4,
})),
lastTrip: vi.fn(() => null),
} as unknown as AtlasService,
new RateLimitService(),
);
}
// ---------------------------------------------------------------------------
// The grant on the request
// ---------------------------------------------------------------------------
describe('public-api scopes — the grant on the request', () => {
it('PUBAPI-SCOPE-U001: a request the guard never touched may read nothing', () => {
// Fail closed. A route mounted without the guard is a bug, and the answer to
// a bug in an authorisation path is "no", never "everything".
expect(grantedScopes({} as Request)).toEqual([]);
});
it('PUBAPI-SCOPE-U002: a resolved grant is what the request reports, verbatim', () => {
expect(grantedScopes(req(limited('trips', 'days')))).toEqual(['trips', 'days']);
expect(grantedScopes(req(ALL))).toEqual([...PUBLIC_API_SCOPES]);
});
it('PUBAPI-SCOPE-U003: a missing scope is a 403 with a code of its own, never a 401 or a 404', () => {
// 401 would tell an integrator to re-mint a key that is perfectly valid; 404
// would send them looking for a trip that is right there. Both are worse than
// saying what actually happened.
expect(thrown(() => requireScope(req(limited('trips')), 'stats'))).toEqual({
status: 403,
body: {
error: 'This API key is not allowed to read stats',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'stats',
},
});
});
it('PUBAPI-SCOPE-U004: a granted scope passes without comment', () => {
expect(() => requireScope(req(limited('trips')), 'trips')).not.toThrow();
for (const scope of PUBLIC_API_SCOPES) {
expect(() => requireScope(req(ALL), scope)).not.toThrow();
}
});
it('PUBAPI-SCOPE-U005: narrowToGrant filters instead of refusing, and keeps the caller’s order', () => {
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips', 'days', 'notes')))).toEqual([
'days',
'notes',
]);
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(ALL))).toEqual([...PUBLIC_API_INCLUDES]);
expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips')))).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// ApiTokenGuard — resolving the grant
// ---------------------------------------------------------------------------
describe('ApiTokenGuard — the grant it leaves behind', () => {
const USER = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User;
function contextWith(headers: Record<string, string | undefined>) {
const request: Record<string, unknown> = { headers };
return {
switchToHttp: () => ({ getRequest: () => request }),
req: request,
} as unknown as ExecutionContext & { req: Record<string, unknown> };
}
function guardWith(result: { user: User; grant: PublicApiGrant } | null) {
const verifyApiTokenWithGrant = vi.fn().mockReturnValue(result);
return {
guard: new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService),
verifyApiTokenWithGrant,
};
}
it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', () => {
const grant = limited('trips');
const { guard, verifyApiTokenWithGrant } = guardWith({ user: USER, grant });
const ctx = contextWith({ authorization: 'Bearer trek_abc123' });
expect(guard.canActivate(ctx)).toBe(true);
expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123');
expect(ctx.req.user).toBe(USER);
expect(ctx.req.apiToken).toBe(grant);
// The grant belongs to the credential, not to the person: nothing of it may
// end up on `user`, which every other auth path in TREK also reads.
expect(USER).not.toHaveProperty('scopes');
expect(USER).not.toHaveProperty('scope_mode');
});
/**
* The two refusals predate scopes and are load-bearing for every integration
* that already handles them. Adding a third answer must not reword them.
*/
it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', () => {
const { guard: noCredential } = guardWith(null);
expect(thrown(() => noCredential.canActivate(contextWith({})))).toEqual({
status: 401,
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
});
const { guard: unknownToken } = guardWith(null);
expect(
thrown(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
).toEqual({
status: 401,
body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
});
});
it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', () => {
const { guard } = guardWith(null);
const ctx = contextWith({ authorization: 'Bearer trek_nope' });
thrown(() => guard.canActivate(ctx));
expect(ctx.req.apiToken).toBeUndefined();
expect(ctx.req.user).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// The two controllers behind the guard
// ---------------------------------------------------------------------------
describe('PublicApiController — what a narrowed key reaches', () => {
it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', () => {
const listTrips = vi.fn().mockReturnValue([TRIP]);
const listBucketList = vi.fn().mockReturnValue([]);
const getTrip = vi.fn().mockReturnValue(TRIP);
const ctl = apiController({ listTrips, listBucketList, getTrip });
expect(ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] });
expect(ctl.listBucketList(req(ALL))).toEqual({ items: [] });
expect(ctl.getTrip(req(ALL), '12', undefined)).toEqual(TRIP);
expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES]);
});
it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', () => {
const listTrips = vi.fn().mockReturnValue([TRIP]);
expect(apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] });
});
it('PUBAPI-SCOPE-U022: and is refused the bucket list before the service is ever asked', () => {
const listBucketList = vi.fn();
const res = thrown(() => apiController({ listBucketList }).listBucketList(req(limited('trips'))));
expect(res.status).toBe(403);
expect(res.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN', required_scope: 'bucket-list' });
// The refusal has to happen before the read, not after it: a 403 carrying
// rows that were already fetched is one careless `return` from leaking them.
expect(listBucketList).not.toHaveBeenCalled();
});
it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', () => {
const listTrips = vi.fn();
const getTrip = vi.fn();
const ctl = apiController({ listTrips, getTrip });
expect(thrown(() => ctl.listTrips(req(limited('bucket-list')))).status).toBe(403);
expect(thrown(() => ctl.getTrip(req(limited('bucket-list')), '12', undefined)).status).toBe(403);
expect(listTrips).not.toHaveBeenCalled();
expect(getTrip).not.toHaveBeenCalled();
});
it('PUBAPI-SCOPE-U024: a trip asked for without include is narrowed, not refused', () => {
const getTrip = vi.fn().mockReturnValue(TRIP);
apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', undefined);
// `include` absent means "everything"; refusing a key for wanting sections it
// never named would make a narrow key unable to read a trip at all.
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']);
});
it('PUBAPI-SCOPE-U025: a trips-only key gets the summary and nothing that hangs off it', () => {
const getTrip = vi.fn().mockReturnValue(TRIP);
apiController({ getTrip }).getTrip(req(limited('trips')), '12', undefined);
expect(getTrip).toHaveBeenCalledWith(12, 7, []);
});
it('PUBAPI-SCOPE-U026: an empty include is treated like an absent one, and narrowed too', () => {
const getTrip = vi.fn().mockReturnValue(TRIP);
apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', ' ');
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days']);
});
it('PUBAPI-SCOPE-U027: a section named explicitly and not granted is a 403, not a silent drop', () => {
const getTrip = vi.fn();
const res = thrown(() =>
apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', 'days,places'),
);
expect(res).toEqual({
status: 403,
body: {
error: 'This API key is not allowed to read places',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'places',
},
});
expect(getTrip).not.toHaveBeenCalled();
});
it('PUBAPI-SCOPE-U028: an include the key covers is passed through untouched', () => {
const getTrip = vi.fn().mockReturnValue(TRIP);
apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', 'days, notes');
expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']);
});
it('PUBAPI-SCOPE-U029: a bad id is still a 400 — the scope check does not swallow it', () => {
const getTrip = vi.fn();
expect(thrown(() => apiController({ getTrip }).getTrip(req(ALL), 'abc', undefined)).status).toBe(400);
expect(getTrip).not.toHaveBeenCalled();
});
});
describe('PublicStatsController — the widest answer on the surface', () => {
it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', () => {
// The route lives in atlas/ and imports the check from public-api/. If it ever
// stops calling it, nothing else in that module would notice.
const res = thrown(() => statsController().stats(req(limited('trips'))));
expect(res).toEqual({
status: 403,
body: {
error: 'This API key is not allowed to read stats',
code: 'API_SCOPE_FORBIDDEN',
required_scope: 'stats',
},
});
});
it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', () => {
expect(statsController().stats(req(limited('stats')))).toMatchObject({
total_trips: 1,
total_countries: 1,
});
});
it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', () => {
expect(statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 });
});
});
// ---------------------------------------------------------------------------
// The column: what is written, and what is read back out of it
// ---------------------------------------------------------------------------
describe('TokenService — storing and resolving a grant', () => {
const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService());
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
vi.clearAllMocks();
});
afterAll(() => {
testDb.close();
});
const rowFor = (id: number) =>
testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as {
kind: string;
scope_mode: string | null;
api_scopes: string | null;
};
const mint = (userId: number, name: string, scopes?: readonly string[]) =>
tokens.createApiToken(userId, name, scopes).token as {
id: number;
raw_token: string;
scope_mode: string;
scopes: string[];
};
it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich');
// 'all' is written explicitly: a NULL sentinel here would mean a bug that
// drops the column silently grants everything.
expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null });
expect(created.scope_mode).toBe('all');
expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]);
});
it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich').raw_token;
const resolved = tokens.verifyApiTokenWithGrant(raw);
expect(resolved?.user.id).toBe(user.id);
expect(resolved?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES] });
});
/**
* The backwards-compatibility case, at the layer that decides it. A row written
* before the columns existed carries neither value; the ALTER backfills
* 'all', and a DatabaseService that hands back NULL anyway must land in the
* same place. Both are "nobody ever narrowed this key".
*/
it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', () => {
const noColumns = new TokenService(
{
get: () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }),
run: () => ({ changes: 1 }),
} as unknown as DatabaseService,
new EphemeralTokenService(),
);
expect(noColumns.verifyApiTokenWithGrant('trek_legacy')?.grant).toEqual({
mode: 'all',
scopes: [...PUBLIC_API_SCOPES],
});
});
it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['stats', 'trips']);
expect(rowFor(created.id)).toEqual({
kind: 'api',
scope_mode: 'limited',
// Canonical order, not the caller's: two keys with the same access must
// read identically in the settings list.
api_scopes: JSON.stringify(['trips', 'stats']),
});
expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] });
});
it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich', ['trips', 'days']).raw_token;
expect(tokens.verifyApiTokenWithGrant(raw)?.grant).toEqual({
mode: 'limited',
scopes: ['trips', 'days'],
});
});
it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']);
expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips']));
});
it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', () => {
const { user } = createUser(testDb);
// Not an empty limited grant: that would mint a key that can read nothing,
// which nobody asked for and which fails hours later at the integration.
const created = mint(user.id, 'Dawarich', ['everything']);
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', () => {
const { user } = createUser(testDb);
expect(rowFor(mint(user.id, 'Dawarich', []).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', () => {
// The direction matters. A row that never said 'limited' is a legacy
// full-access key and stays one; a row that HAS said it is restricted must
// never widen because its list became unreadable — that is the failure this
// whole column exists to prevent. A key that stops working is a support
// ticket; a key that quietly reads every trip is the bug.
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) {
testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
mode: 'limited',
scopes: [],
});
}
});
it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
// What a future version dropping or renaming a scope constant leaves behind.
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['itineraries']), created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
mode: 'limited',
scopes: [],
});
});
it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
mode: 'limited',
scopes: ['trips', 'stats'],
});
});
it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', () => {
const { user } = createUser(testDb);
mint(user.id, 'Wide');
mint(user.id, 'Narrow', ['trips']);
const listed = tokens.listApiTokens(user.id) as Array<Record<string, unknown>>;
expect(listed).toHaveLength(2);
expect(listed.map((t) => [t.name, t.scope_mode, t.scopes])).toEqual(
expect.arrayContaining([
['Wide', 'all', [...PUBLIC_API_SCOPES]],
['Narrow', 'limited', ['trips']],
]),
);
// Still no hash, and still no raw key.
expect(JSON.stringify(listed)).not.toContain('token_hash');
});
it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', () => {
const { user } = createUser(testDb);
const mcp = tokens.createMcpToken(user.id, 'Assistant').token as { id: number };
// Stored as the column default, because an MCP token carries no read scopes
// and must not look as if it did.
expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
const listed = tokens.listMcpTokens(user.id) as Array<Record<string, unknown>>;
expect(listed).toHaveLength(1);
// The MCP panel cannot act on scopes, so it is not shown two columns that
// would always say the same thing.
expect(listed[0]).not.toHaveProperty('scope_mode');
expect(listed[0]).not.toHaveProperty('scopes');
expect(Object.keys(listed[0]).sort()).toEqual(
['created_at', 'id', 'last_used_at', 'name', 'token_prefix'].sort(),
);
});
it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich', ['trips']).raw_token;
expect(tokens.verifyMcpToken(raw)).toBeNull();
expect(tokens.verifyApiTokenWithGrant(raw)).not.toBeNull();
});
it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
}).last_used_at,
).toBeNull();
tokens.verifyApiTokenWithGrant(created.raw_token);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
}).last_used_at,
).not.toBeNull();
});
// -------------------------------------------------------------------------
// The mint routes. AuthController is constructed directly (repo convention)
// over the real TokenService above, so the body really does reach the column.
// -------------------------------------------------------------------------
const authController = () =>
new AuthController(
{ setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() } as unknown as AuthService,
{} as UserProfileService,
tokens,
new RateLimitService(),
{ writeAudit: vi.fn() } as unknown as AuditService,
new RuntimeEnvService(),
{ put: vi.fn() } as unknown as StorageService,
);
const httpReq = { ip: '9.9.9.9', headers: {} } as Request;
it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', () => {
const { user } = createUser(testDb);
const created = authController().createApiToken(
{ id: user.id } as User,
{ name: 'Dawarich', scopes: ['trips', 'days'] },
httpReq,
).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string };
expect(rowFor(created.id)).toMatchObject({
scope_mode: 'limited',
api_scopes: JSON.stringify(['trips', 'days']),
});
// The response carries the grant, so the panel can show it without a re-read.
expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'days'] });
expect(created.raw_token).toMatch(/^trek_/);
});
it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', () => {
const { user } = createUser(testDb);
const ctl = authController();
ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq);
expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
tokens: [expect.objectContaining({ name: 'Dawarich', scope_mode: 'limited', scopes: ['bucket-list'] })],
});
});
it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', () => {
const { user } = createUser(testDb);
const ctl = authController();
const created = ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq).token as {
id: number;
};
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })],
});
});
it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', () => {
const { user } = createUser(testDb);
const ctl = authController();
// The DTO is a different schema on purpose, so the field never survives the
// pipe — and even handed straight to the controller it changes nothing.
const created = ctl.createMcpToken(
{ id: user.id } as User,
{ name: 'Assistant', scopes: ['trips'] } as { name: string },
httpReq,
).token as Record<string, unknown>;
expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
expect(created).not.toHaveProperty('scope_mode');
expect(created).not.toHaveProperty('scopes');
expect(ctl.listMcpTokens({ id: user.id } as User).tokens[0]).not.toHaveProperty('scope_mode');
});
it('PUBAPI-SCOPE-U064: the two create schemas differ, which is what keeps them apart', () => {
const api = apiTokenCreateRequestSchema.safeParse({ name: 'Dawarich', scopes: ['trips'] });
expect(api.success && api.data.scopes).toEqual(['trips']);
// An unknown section is refused at the edge rather than quietly sanitised, so
// a typo in an integration's setup script is visible immediately.
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: ['passwords'] }).success).toBe(false);
// An empty array would mint a key that reads nothing; omitting the field is
// how you ask for everything.
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: [] }).success).toBe(false);
expect(apiTokenCreateRequestSchema.safeParse({ name: 'x' }).success).toBe(true);
const mcp = mcpTokenCreateRequestSchema.safeParse({ name: 'Assistant', scopes: ['trips'] });
expect(mcp.success).toBe(true);
// Parsed and dropped: the MCP route cannot be handed read scopes by accident.
expect(mcp.success && mcp.data).toEqual({ name: 'Assistant' });
});
});
@@ -9,7 +9,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { PUBLIC_API_INCLUDES } from '@trek/shared';
import { PUBLIC_API_INCLUDES, PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared';
import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
import type { PublicApiService } from '../../../src/nest/public-api/public-api.service';
import type { RateLimitService } from '../../../src/nest/common/rate-limit.service';
@@ -40,9 +40,15 @@ function makeController(svc: Partial<PublicApiService>, allow = true) {
return new PublicApiController(svc as PublicApiService, rl);
}
const req = (userId = 7) => ({ user: { id: userId } }) as Request;
/** A request the guard never touched — the shape the controller must refuse. */
const reqWithoutUser = () => ({}) as Request;
/**
* The guard leaves two things behind (#2279): who is calling, and what their key
* may read. Every request here carries the full grant, because these cases are
* about shaping; the narrowed ones live in public-api-scopes.test.ts.
*/
const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
const req = (userId = 7) => ({ user: { id: userId }, apiToken: FULL_GRANT }) as Request;
/** A request the guard left no user on — the shape the controller must refuse. */
const reqWithoutUser = () => ({ apiToken: FULL_GRANT }) as Request;
function thrown(fn: () => unknown): { status: number; body: unknown } {
try {
@@ -8,6 +8,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
import { PUBLIC_API_SCOPES } from '@trek/shared';
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
import type { TokenService } from '../../../src/nest/tokens/token.service';
import type { User } from '../../../src/types';
@@ -22,8 +23,17 @@ function contextWith(headers: Record<string, string | undefined>) {
} as unknown as ExecutionContext & { req: Record<string, unknown> };
}
/**
* The guard resolves the user and the key's read grant in one lookup (#2279).
* These cases are about the credential, so every accepted token here carries the
* full grant; what a narrowed one may read is public-api-scopes.test.ts.
*/
function makeGuard(verify: (raw: string) => User | null) {
return new ApiTokenGuard({ verifyApiToken: verify } as unknown as TokenService);
const verifyApiTokenWithGrant = (raw: string) => {
const user = verify(raw);
return user ? { user, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } } : null;
};
return new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService);
}
/** Run the guard, expecting a refusal; return its { status, body }. */
@@ -70,11 +80,13 @@ describe('ApiTokenGuard', () => {
});
it('never reaches for the MCP verifier, so the two credentials cannot swap', () => {
const verifyApiToken = vi.fn().mockReturnValue(USER);
const verifyApiTokenWithGrant = vi
.fn()
.mockReturnValue({ user: USER, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } });
const verifyMcpToken = vi.fn();
const guard = new ApiTokenGuard({ verifyApiToken, verifyMcpToken } as unknown as TokenService);
const guard = new ApiTokenGuard({ verifyApiTokenWithGrant, verifyMcpToken } as unknown as TokenService);
guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' }));
expect(verifyApiToken).toHaveBeenCalledTimes(1);
expect(verifyApiTokenWithGrant).toHaveBeenCalledTimes(1);
expect(verifyMcpToken).not.toHaveBeenCalled();
});