diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 8313aaee9..595451215 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -21,6 +21,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
+ type ApiTokenCreateRequest, type PublicApiScope,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
@@ -342,7 +343,10 @@ export const authApi = {
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
- create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
+ // `scopes` narrows what the key may read (#2279). Omitted means everything,
+ // which is what every key minted before scopes existed still does.
+ create: (name: string, scopes?: PublicApiScope[]) =>
+ apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
diff --git a/client/src/components/Settings/ApiKeysSection.tsx b/client/src/components/Settings/ApiKeysSection.tsx
index b33be69f2..4c0659507 100644
--- a/client/src/components/Settings/ApiKeysSection.tsx
+++ b/client/src/components/Settings/ApiKeysSection.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react'
+import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
import Section from './Section'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -24,6 +25,9 @@ interface ApiKey {
token_prefix: string
created_at: string
last_used_at: string | null
+ /** 'all' for every key minted before scopes existed, and for any key created without narrowing. */
+ scope_mode?: 'all' | 'limited'
+ scopes?: PublicApiScope[]
}
export default function ApiKeysSection(): React.ReactElement {
@@ -37,6 +41,12 @@ export default function ApiKeysSection(): React.ReactElement {
const [created, setCreated] = useState(null)
const [deleteId, setDeleteId] = useState(null)
const [copied, setCopied] = useState(false)
+ /**
+ * What the new key may read. Starts as everything, because that is what a key
+ * did before this existed and what most integrations want — narrowing is a
+ * deliberate act, and the dialog should not make the common case work harder.
+ */
+ const [newScopes, setNewScopes] = useState>(new Set(PUBLIC_API_SCOPES))
useEffect(() => {
authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {})
@@ -46,10 +56,23 @@ export default function ApiKeysSection(): React.ReactElement {
if (!newName.trim() || creating) return
setCreating(true)
try {
- const d = await authApi.apiKeys.create(newName.trim())
+ // All of them selected means "no narrowing", which is what the server
+ // stores as `all` — sending the full list would record it as a limited key
+ // that happens to allow everything, and a section added in a later version
+ // would then be refused for a key nobody meant to restrict.
+ const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined
+ const d = await authApi.apiKeys.create(newName.trim(), narrowed)
setCreated(d.token.raw_token)
setKeys(prev => [
- { id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null },
+ {
+ id: d.token.id,
+ name: d.token.name,
+ token_prefix: d.token.token_prefix,
+ created_at: d.token.created_at,
+ last_used_at: null,
+ scope_mode: d.token.scope_mode,
+ scopes: d.token.scopes,
+ },
...prev,
])
setNewName('')
@@ -83,6 +106,16 @@ export default function ApiKeysSection(): React.ReactElement {
setModalOpen(false)
setCreated(null)
setNewName('')
+ setNewScopes(new Set(PUBLIC_API_SCOPES))
+ }
+
+ const toggleScope = (scope: PublicApiScope) => {
+ setNewScopes(prev => {
+ const next = new Set(prev)
+ if (next.has(scope)) next.delete(scope)
+ else next.add(scope)
+ return next
+ })
}
return (
@@ -114,6 +147,14 @@ export default function ApiKeysSection(): React.ReactElement {
· {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)}
)}
+ {/* What the key may read. Shown on the row rather than behind a
+ detail view: the whole reason to narrow a key is to be able
+ to see later that you did. */}
+
+ {key.scope_mode === 'limited' && key.scopes
+ ? key.scopes.map(scope => t(`settings.apiScopes.${scope}`)).join(', ')
+ : t('settings.apiScopes.all')}
+
setDeleteId(key.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@@ -144,12 +185,31 @@ export default function ApiKeysSection(): React.ReactElement {
autoFocus />
{t('settings.apiKeys.modal.nameHint')}
+
+
+
+ {t('settings.apiScopes.title')}
+
+
{t('settings.apiScopes.hint')}
+
+ {PUBLIC_API_SCOPES.map(scope => (
+
+ toggleScope(scope)}
+ />
+ {t(`settings.apiScopes.${scope}`)}
+
+ ))}
+
+
{t('common.cancel')}
-
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts
index 3e2d7e642..e173b0ff1 100644
--- a/server/src/nest/public-api/api-token.guard.ts
+++ b/server/src/nest/public-api/api-token.guard.ts
@@ -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;
}
}
diff --git a/server/src/nest/public-api/public-api-request.ts b/server/src/nest/public-api/public-api-request.ts
index 0e9340888..dbed53680 100644
--- a/server/src/nest/public-api/public-api-request.ts
+++ b/server/src/nest/public-api/public-api-request.ts
@@ -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
(sections: readonly T[], req: Request): T[] {
+ const granted = new Set(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;
diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts
index 5d1c0a61c..48775b9af 100644
--- a/server/src/nest/public-api/public-api.controller.ts
+++ b/server/src/nest/public-api/public-api.controller.ts
@@ -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);
}
diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts
index a5df4ca80..294494c2c 100644
--- a/server/src/nest/tokens/token.service.ts
+++ b/server/src/nest/tokens/token.service.ts
@@ -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(
+ '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 } {
+ private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { 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 };
@@ -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(`
+ 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 ?? [] };
+}
diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts
index 2c45b8076..ad1fcb642 100644
--- a/server/tests/e2e/public-api.e2e.test.ts
+++ b/server/tests/e2e/public-api.e2e.test.ts
@@ -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>;
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();
+ });
+ });
});
diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts
new file mode 100644
index 000000000..5e4746e96
--- /dev/null
+++ b/server/tests/unit/nest/public-api-scopes.test.ts
@@ -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) {
+ 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) {
+ const request: Record = { headers };
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ req: request,
+ } as unknown as ExecutionContext & { req: Record };
+ }
+
+ 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>;
+ 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>;
+ 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;
+
+ 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' });
+ });
+});
diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts
index bf4710f94..afa1cc6ce 100644
--- a/server/tests/unit/nest/public-api.controller.test.ts
+++ b/server/tests/unit/nest/public-api.controller.test.ts
@@ -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, 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 {
diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts
index a58ebd12d..a5094534e 100644
--- a/server/tests/unit/nest/public-api.guard.test.ts
+++ b/server/tests/unit/nest/public-api.guard.test.ts
@@ -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) {
} as unknown as ExecutionContext & { req: Record };
}
+/**
+ * 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();
});
diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts
index f66ee48f0..2e4405b2a 100644
--- a/shared/src/i18n/ar/settings.ts
+++ b/shared/src/i18n/ar/settings.ts
@@ -537,6 +537,21 @@ const settings: TranslationStrings = {
'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'ما يمكن لهذا المفتاح قراءته',
+ 'settings.apiScopes.hint':
+ 'اترك كل شيء مفعّلاً لمفتاح ينبغي أن يرى كل شيء. أي خيار توقفه يُرفض لهذا المفتاح، لا يُحذف من الإجابة فحسب.',
+ 'settings.apiScopes.all': 'كل شيء',
+ 'settings.apiScopes.limited': '{count} من {total}',
+ 'settings.apiScopes.trips': 'الرحلات',
+ 'settings.apiScopes.days': 'الأيام',
+ 'settings.apiScopes.places': 'الأماكن',
+ 'settings.apiScopes.notes': 'ملاحظات اليوم',
+ 'settings.apiScopes.reservations': 'الحجوزات',
+ 'settings.apiScopes.accommodations': 'الإقامة',
+ 'settings.apiScopes.travellers': 'من سيأتي',
+ 'settings.apiScopes.bucket-list': 'قائمة الأمنيات',
+ 'settings.apiScopes.stats': 'الإجماليات',
'settings.apiKeys.title': 'مفاتيح API',
'settings.apiKeys.description': 'مفاتيح لواجهة API العامة، حتى تتمكن برامج أخرى من قراءة رحلاتك. للقراءة فقط: لا يمكن للمفتاح تغيير أي شيء أو حذفه.',
'settings.apiKeys.create': 'إنشاء مفتاح',
diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts
index e8086a5ef..17001478c 100644
--- a/shared/src/i18n/br/settings.ts
+++ b/shared/src/i18n/br/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'O que esta chave pode ler',
+ 'settings.apiScopes.hint':
+ 'Deixe tudo ligado para uma chave que deve enxergar tudo. O que você desligar é recusado para esta chave, não apenas omitido da resposta.',
+ 'settings.apiScopes.all': 'Tudo',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viagens',
+ 'settings.apiScopes.days': 'Dias',
+ 'settings.apiScopes.places': 'Lugares',
+ 'settings.apiScopes.notes': 'Notas do dia',
+ 'settings.apiScopes.reservations': 'Reservas',
+ 'settings.apiScopes.accommodations': 'Hospedagem',
+ 'settings.apiScopes.travellers': 'Quem vai',
+ 'settings.apiScopes.bucket-list': 'Lista de desejos',
+ 'settings.apiScopes.stats': 'Totais',
'settings.apiKeys.title': 'Chaves de API',
'settings.apiKeys.description': 'Chaves para a API pública, para que outros softwares possam ler suas viagens. Somente leitura: uma chave não pode alterar nem excluir nada.',
'settings.apiKeys.create': 'Criar chave',
diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts
index 1f7007457..d8620f1b0 100644
--- a/shared/src/i18n/ca/settings.ts
+++ b/shared/src/i18n/ca/settings.ts
@@ -555,6 +555,21 @@ const settings: TranslationStrings = {
'Dibuixa automàticament al mapa la ruta de cada vol, tren i altra reserva — no cal activar-la una per una.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Què pot llegir aquesta clau',
+ 'settings.apiScopes.hint':
+ 'Deixa-ho tot activat per a una clau que ho hagi de veure tot. El que desactivis es denega per a aquesta clau, no només es deixa fora de la resposta.',
+ 'settings.apiScopes.all': 'Tot',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viatges',
+ 'settings.apiScopes.days': 'Dies',
+ 'settings.apiScopes.places': 'Llocs',
+ 'settings.apiScopes.notes': 'Notes del dia',
+ 'settings.apiScopes.reservations': 'Reserves',
+ 'settings.apiScopes.accommodations': 'Allotjament',
+ 'settings.apiScopes.travellers': 'Qui hi va',
+ 'settings.apiScopes.bucket-list': 'Llista de desitjos',
+ 'settings.apiScopes.stats': 'Totals',
'settings.apiKeys.title': 'Claus API',
'settings.apiKeys.description': 'Claus per a l\'API pública, perquè altres programes puguin llegir els teus viatges. Només lectura: una clau no pot canviar ni esborrar res.',
'settings.apiKeys.create': 'Crea una clau',
diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts
index 099c66256..12da1ee09 100644
--- a/shared/src/i18n/cs/settings.ts
+++ b/shared/src/i18n/cs/settings.ts
@@ -544,6 +544,21 @@ const settings: TranslationStrings = {
'Automaticky zobrazí na mapě trasu každého letu, vlaku a jiné rezervace, aniž byste ji museli zapínat jednotlivě.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Co smí tento klíč číst',
+ 'settings.apiScopes.hint':
+ 'Pro klíč, který má vidět všechno, nechte vše zapnuté. Cokoli vypnete, bude tomuto klíči odepřeno, nejen vynecháno z odpovědi.',
+ 'settings.apiScopes.all': 'Vše',
+ 'settings.apiScopes.limited': '{count} z {total}',
+ 'settings.apiScopes.trips': 'Cesty',
+ 'settings.apiScopes.days': 'Dny',
+ 'settings.apiScopes.places': 'Místa',
+ 'settings.apiScopes.notes': 'Poznámky ke dni',
+ 'settings.apiScopes.reservations': 'Rezervace',
+ 'settings.apiScopes.accommodations': 'Ubytování',
+ 'settings.apiScopes.travellers': 'Kdo jede',
+ 'settings.apiScopes.bucket-list': 'Seznam přání',
+ 'settings.apiScopes.stats': 'Souhrny',
'settings.apiKeys.title': 'Klíče API',
'settings.apiKeys.description': 'Klíče pro veřejné API, aby jiný software mohl číst tvoje cesty. Jen pro čtení: klíč nemůže nic měnit ani mazat.',
'settings.apiKeys.create': 'Vytvořit klíč',
diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts
index d9e10d945..454e407e5 100644
--- a/shared/src/i18n/de/settings.ts
+++ b/shared/src/i18n/de/settings.ts
@@ -555,6 +555,21 @@ const settings: TranslationStrings = {
'Zeigt die Route für jeden Flug, jede Zugfahrt und jede andere Buchung automatisch auf der Karte an – ohne sie einzeln aktivieren zu müssen.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Was dieser Schlüssel lesen darf',
+ 'settings.apiScopes.hint':
+ 'Lass alles an, wenn der Schlüssel alles sehen soll. Was du abschaltest, wird für diesen Schlüssel abgelehnt und nicht nur aus der Antwort weggelassen.',
+ 'settings.apiScopes.all': 'Alles',
+ 'settings.apiScopes.limited': '{count} von {total}',
+ 'settings.apiScopes.trips': 'Reisen',
+ 'settings.apiScopes.days': 'Tage',
+ 'settings.apiScopes.places': 'Orte',
+ 'settings.apiScopes.notes': 'Tagesnotizen',
+ 'settings.apiScopes.reservations': 'Buchungen',
+ 'settings.apiScopes.accommodations': 'Unterkunft',
+ 'settings.apiScopes.travellers': 'Wer mitkommt',
+ 'settings.apiScopes.bucket-list': 'Wunschliste',
+ 'settings.apiScopes.stats': 'Summen',
'settings.apiKeys.title': 'API-Schlüssel',
'settings.apiKeys.description': 'Schlüssel für die öffentliche API, damit andere Software deine Reisen lesen kann. Nur lesend: ein Schlüssel kann nichts ändern oder löschen.',
'settings.apiKeys.create': 'Schlüssel erstellen',
diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts
index bd2f21608..c4182f486 100644
--- a/shared/src/i18n/en/settings.ts
+++ b/shared/src/i18n/en/settings.ts
@@ -543,6 +543,21 @@ const settings: TranslationStrings = {
'Automatically draw the route for every flight, train and other booking on the map — no need to switch it on per item.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'What this key may read',
+ 'settings.apiScopes.hint':
+ 'Leave everything on for a key that should see all of it. Anything you switch off is refused for this key, not just left out of the answer.',
+ 'settings.apiScopes.all': 'Everything',
+ 'settings.apiScopes.limited': '{count} of {total}',
+ 'settings.apiScopes.trips': 'Trips',
+ 'settings.apiScopes.days': 'Days',
+ 'settings.apiScopes.places': 'Places',
+ 'settings.apiScopes.notes': 'Day notes',
+ 'settings.apiScopes.reservations': 'Bookings',
+ 'settings.apiScopes.accommodations': 'Accommodation',
+ 'settings.apiScopes.travellers': 'Who is coming',
+ 'settings.apiScopes.bucket-list': 'Wishlist',
+ 'settings.apiScopes.stats': 'Totals',
'settings.apiKeys.title': 'API Keys',
'settings.apiKeys.description': 'Keys for the public API, so other software can read your trips. Read-only: a key cannot change or delete anything.',
'settings.apiKeys.create': 'Create key',
diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts
index d8ab3e55c..ba5d36976 100644
--- a/shared/src/i18n/es/settings.ts
+++ b/shared/src/i18n/es/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Muestra automáticamente en el mapa la ruta de cada vuelo, tren y otra reserva, sin necesidad de activarla una por una.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Qué puede leer esta clave',
+ 'settings.apiScopes.hint':
+ 'Déjalo todo activado para una clave que deba verlo todo. Lo que desactives se le deniega a esta clave, no solo se omite de la respuesta.',
+ 'settings.apiScopes.all': 'Todo',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viajes',
+ 'settings.apiScopes.days': 'Días',
+ 'settings.apiScopes.places': 'Lugares',
+ 'settings.apiScopes.notes': 'Notas del día',
+ 'settings.apiScopes.reservations': 'Reservas',
+ 'settings.apiScopes.accommodations': 'Alojamiento',
+ 'settings.apiScopes.travellers': 'Quién viene',
+ 'settings.apiScopes.bucket-list': 'Lista de deseos',
+ 'settings.apiScopes.stats': 'Totales',
'settings.apiKeys.title': 'Claves API',
'settings.apiKeys.description': 'Claves para la API pública, para que otro software pueda leer tus viajes. Solo lectura: una clave no puede cambiar ni borrar nada.',
'settings.apiKeys.create': 'Crear clave',
diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts
index a36a95eeb..edb9d9b1d 100644
--- a/shared/src/i18n/fr/settings.ts
+++ b/shared/src/i18n/fr/settings.ts
@@ -561,6 +561,21 @@ const settings: TranslationStrings = {
"Affiche automatiquement sur la carte l'itinéraire de chaque vol, trajet en train et autre réservation, sans avoir à l'activer un par un.",
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Ce que cette clé peut lire',
+ 'settings.apiScopes.hint':
+ 'Laissez tout activé pour une clé qui doit tout voir. Ce que vous désactivez est refusé à cette clé, et pas seulement omis de la réponse.',
+ 'settings.apiScopes.all': 'Tout',
+ 'settings.apiScopes.limited': '{count} sur {total}',
+ 'settings.apiScopes.trips': 'Voyages',
+ 'settings.apiScopes.days': 'Jours',
+ 'settings.apiScopes.places': 'Lieux',
+ 'settings.apiScopes.notes': 'Notes du jour',
+ 'settings.apiScopes.reservations': 'Réservations',
+ 'settings.apiScopes.accommodations': 'Hébergement',
+ 'settings.apiScopes.travellers': 'Qui participe',
+ 'settings.apiScopes.bucket-list': 'Liste de souhaits',
+ 'settings.apiScopes.stats': 'Totaux',
'settings.apiKeys.title': 'Clés API',
'settings.apiKeys.description': 'Clés pour l\'API publique, afin que d\'autres logiciels puissent lire vos voyages. Lecture seule : une clé ne peut rien modifier ni supprimer.',
'settings.apiKeys.create': 'Créer une clé',
diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts
index 61a125201..ef4a4dae9 100644
--- a/shared/src/i18n/gr/settings.ts
+++ b/shared/src/i18n/gr/settings.ts
@@ -564,6 +564,21 @@ const settings: TranslationStrings = {
'Εμφανίζει αυτόματα στον χάρτη τη διαδρομή κάθε πτήσης, τρένου και άλλης κράτησης, χωρίς να χρειάζεται να την ενεργοποιείτε μία προς μία.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Τι μπορεί να διαβάσει αυτό το κλειδί',
+ 'settings.apiScopes.hint':
+ 'Αφήστε τα όλα ενεργά για ένα κλειδί που πρέπει να τα βλέπει όλα. Ό,τι απενεργοποιήσετε απορρίπτεται για αυτό το κλειδί, δεν παραλείπεται απλώς από την απάντηση.',
+ 'settings.apiScopes.all': 'Τα πάντα',
+ 'settings.apiScopes.limited': '{count} από {total}',
+ 'settings.apiScopes.trips': 'Ταξίδια',
+ 'settings.apiScopes.days': 'Ημέρες',
+ 'settings.apiScopes.places': 'Τοποθεσίες',
+ 'settings.apiScopes.notes': 'Σημειώσεις ημέρας',
+ 'settings.apiScopes.reservations': 'Κρατήσεις',
+ 'settings.apiScopes.accommodations': 'Διαμονή',
+ 'settings.apiScopes.travellers': 'Ποιοι συμμετέχουν',
+ 'settings.apiScopes.bucket-list': 'Λίστα επιθυμιών',
+ 'settings.apiScopes.stats': 'Σύνολα',
'settings.apiKeys.title': 'Κλειδιά API',
'settings.apiKeys.description': 'Κλειδιά για το δημόσιο API, ώστε άλλο λογισμικό να μπορεί να διαβάζει τα ταξίδια σου. Μόνο για ανάγνωση: ένα κλειδί δεν μπορεί να αλλάξει ή να διαγράψει τίποτα.',
'settings.apiKeys.create': 'Δημιουργία κλειδιού',
diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts
index 16054699d..75d63aaca 100644
--- a/shared/src/i18n/hu/settings.ts
+++ b/shared/src/i18n/hu/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Automatikusan megjeleníti minden repülőjárat, vonat és egyéb foglalás útvonalát a térképen, nincs szükség egyenkénti bekapcsolásra.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Mit olvashat ez a kulcs',
+ 'settings.apiScopes.hint':
+ 'Hagyj mindent bekapcsolva, ha a kulcs mindent láthat. Amit kikapcsolsz, azt a rendszer megtagadja ettől a kulcstól, nem csak kihagyja a válaszból.',
+ 'settings.apiScopes.all': 'Minden',
+ 'settings.apiScopes.limited': '{count} / {total}',
+ 'settings.apiScopes.trips': 'Utazások',
+ 'settings.apiScopes.days': 'Napok',
+ 'settings.apiScopes.places': 'Helyszínek',
+ 'settings.apiScopes.notes': 'Napi jegyzetek',
+ 'settings.apiScopes.reservations': 'Foglalások',
+ 'settings.apiScopes.accommodations': 'Szállás',
+ 'settings.apiScopes.travellers': 'Kik utaznak',
+ 'settings.apiScopes.bucket-list': 'Kívánságlista',
+ 'settings.apiScopes.stats': 'Összesítések',
'settings.apiKeys.title': 'API-kulcsok',
'settings.apiKeys.description': 'Kulcsok a nyilvános API-hoz, hogy más szoftverek olvashassák az utazásaidat. Csak olvasható: a kulcs semmit nem módosít és nem töröl.',
'settings.apiKeys.create': 'Kulcs létrehozása',
diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts
index d955567d9..489b34f48 100644
--- a/shared/src/i18n/id/settings.ts
+++ b/shared/src/i18n/id/settings.ts
@@ -550,6 +550,21 @@ const settings: TranslationStrings = {
'Menampilkan rute setiap penerbangan, kereta, dan pemesanan lainnya di peta secara otomatis, tanpa perlu mengaktifkannya satu per satu.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Apa yang boleh dibaca kunci ini',
+ 'settings.apiScopes.hint':
+ 'Biarkan semuanya menyala untuk kunci yang boleh melihat semuanya. Apa pun yang kamu matikan akan ditolak untuk kunci ini, bukan sekadar tidak disertakan dalam jawaban.',
+ 'settings.apiScopes.all': 'Semuanya',
+ 'settings.apiScopes.limited': '{count} dari {total}',
+ 'settings.apiScopes.trips': 'Perjalanan',
+ 'settings.apiScopes.days': 'Hari',
+ 'settings.apiScopes.places': 'Tempat',
+ 'settings.apiScopes.notes': 'Catatan harian',
+ 'settings.apiScopes.reservations': 'Pemesanan',
+ 'settings.apiScopes.accommodations': 'Akomodasi',
+ 'settings.apiScopes.travellers': 'Siapa yang ikut',
+ 'settings.apiScopes.bucket-list': 'Daftar keinginan',
+ 'settings.apiScopes.stats': 'Total',
'settings.apiKeys.title': 'Kunci API',
'settings.apiKeys.description': 'Kunci untuk API publik, agar perangkat lunak lain dapat membaca perjalananmu. Hanya baca: kunci tidak dapat mengubah atau menghapus apa pun.',
'settings.apiKeys.create': 'Buat kunci',
diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts
index 585b92183..cb0263f36 100644
--- a/shared/src/i18n/it/settings.ts
+++ b/shared/src/i18n/it/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Mostra automaticamente sulla mappa il percorso di ogni volo, treno e altra prenotazione, senza doverlo attivare singolarmente.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Cosa può leggere questa chiave',
+ 'settings.apiScopes.hint':
+ 'Lascia tutto attivo per una chiave che deve vedere ogni cosa. Ciò che disattivi viene rifiutato per questa chiave, non semplicemente omesso dalla risposta.',
+ 'settings.apiScopes.all': 'Tutto',
+ 'settings.apiScopes.limited': '{count} di {total}',
+ 'settings.apiScopes.trips': 'Viaggi',
+ 'settings.apiScopes.days': 'Giorni',
+ 'settings.apiScopes.places': 'Luoghi',
+ 'settings.apiScopes.notes': 'Note del giorno',
+ 'settings.apiScopes.reservations': 'Prenotazioni',
+ 'settings.apiScopes.accommodations': 'Alloggio',
+ 'settings.apiScopes.travellers': 'Chi partecipa',
+ 'settings.apiScopes.bucket-list': 'Lista dei desideri',
+ 'settings.apiScopes.stats': 'Totali',
'settings.apiKeys.title': 'Chiavi API',
'settings.apiKeys.description': 'Chiavi per l\'API pubblica, così altri software possono leggere i tuoi viaggi. Sola lettura: una chiave non può modificare né eliminare nulla.',
'settings.apiKeys.create': 'Crea chiave',
diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts
index 92254920e..675499ed7 100644
--- a/shared/src/i18n/ja/settings.ts
+++ b/shared/src/i18n/ja/settings.ts
@@ -523,6 +523,21 @@ const settings: TranslationStrings = {
'フライトや電車などすべての予約のルートを、個別にオンにしなくても地図上に自動的に表示します。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'このキーが読み取れる範囲',
+ 'settings.apiScopes.hint':
+ 'すべて読み取らせたいキーは、全部オンのままにしてください。オフにした項目は、応答から省かれるのではなく、このキーには拒否されます。',
+ 'settings.apiScopes.all': 'すべて',
+ 'settings.apiScopes.limited': '{total}件中{count}件',
+ 'settings.apiScopes.trips': '旅行',
+ 'settings.apiScopes.days': '日程',
+ 'settings.apiScopes.places': '場所',
+ 'settings.apiScopes.notes': '日ごとのメモ',
+ 'settings.apiScopes.reservations': '予約',
+ 'settings.apiScopes.accommodations': '宿泊',
+ 'settings.apiScopes.travellers': '同行者',
+ 'settings.apiScopes.bucket-list': 'ウィッシュリスト',
+ 'settings.apiScopes.stats': '合計',
'settings.apiKeys.title': 'API キー',
'settings.apiKeys.description': '公開 API 用のキーです。ほかのソフトウェアが旅程を読み取れるようになります。読み取り専用で、変更や削除はできません。',
'settings.apiKeys.create': 'キーを作成',
diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts
index 09b7cefd6..eb09abca2 100644
--- a/shared/src/i18n/ko/settings.ts
+++ b/shared/src/i18n/ko/settings.ts
@@ -540,6 +540,21 @@ const settings: TranslationStrings = {
'항공편, 기차 등 모든 예약의 경로를 개별적으로 켤 필요 없이 지도에 자동으로 표시합니다.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '이 키가 읽을 수 있는 항목',
+ 'settings.apiScopes.hint':
+ '모든 것을 볼 수 있어야 하는 키라면 전부 켜 두세요. 끈 항목은 이 키에 대해 응답에서 빠지는 정도가 아니라 아예 거부됩니다.',
+ 'settings.apiScopes.all': '전체',
+ 'settings.apiScopes.limited': '{total}개 중 {count}개',
+ 'settings.apiScopes.trips': '여행',
+ 'settings.apiScopes.days': '일자',
+ 'settings.apiScopes.places': '장소',
+ 'settings.apiScopes.notes': '일자 메모',
+ 'settings.apiScopes.reservations': '예약',
+ 'settings.apiScopes.accommodations': '숙소',
+ 'settings.apiScopes.travellers': '동행자',
+ 'settings.apiScopes.bucket-list': '위시리스트',
+ 'settings.apiScopes.stats': '합계',
'settings.apiKeys.title': 'API 키',
'settings.apiKeys.description': '공개 API용 키입니다. 다른 소프트웨어가 여행을 읽을 수 있습니다. 읽기 전용이라 무엇도 바꾸거나 지울 수 없습니다.',
'settings.apiKeys.create': '키 만들기',
diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts
index 1db115f8e..ab6587085 100644
--- a/shared/src/i18n/nl/settings.ts
+++ b/shared/src/i18n/nl/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Toont automatisch de route van elke vlucht, trein en andere boeking op de kaart, zonder dat u dit per boeking hoeft in te schakelen.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Wat deze sleutel mag lezen',
+ 'settings.apiScopes.hint':
+ 'Laat alles aan staan voor een sleutel die alles mag zien. Alles wat je uitzet, wordt voor deze sleutel geweigerd en niet alleen uit het antwoord weggelaten.',
+ 'settings.apiScopes.all': 'Alles',
+ 'settings.apiScopes.limited': '{count} van {total}',
+ 'settings.apiScopes.trips': 'Reizen',
+ 'settings.apiScopes.days': 'Dagen',
+ 'settings.apiScopes.places': 'Plaatsen',
+ 'settings.apiScopes.notes': 'Dagnotities',
+ 'settings.apiScopes.reservations': 'Boekingen',
+ 'settings.apiScopes.accommodations': 'Accommodatie',
+ 'settings.apiScopes.travellers': 'Wie er meegaan',
+ 'settings.apiScopes.bucket-list': 'Wensenlijst',
+ 'settings.apiScopes.stats': 'Totalen',
'settings.apiKeys.title': 'API-sleutels',
'settings.apiKeys.description': 'Sleutels voor de publieke API, zodat andere software je reizen kan lezen. Alleen-lezen: een sleutel kan niets wijzigen of verwijderen.',
'settings.apiKeys.create': 'Sleutel aanmaken',
diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts
index 65b6c39f6..91bde5c92 100644
--- a/shared/src/i18n/pl/settings.ts
+++ b/shared/src/i18n/pl/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Automatycznie pokazuje na mapie trasę każdego lotu, pociągu i innej rezerwacji, bez konieczności włączania jej pojedynczo.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Co ten klucz może odczytywać',
+ 'settings.apiScopes.hint':
+ 'Zostaw wszystko włączone dla klucza, który ma widzieć całość. Wszystko, co wyłączysz, jest dla tego klucza odrzucane, a nie tylko pomijane w odpowiedzi.',
+ 'settings.apiScopes.all': 'Wszystko',
+ 'settings.apiScopes.limited': '{count} z {total}',
+ 'settings.apiScopes.trips': 'Podróże',
+ 'settings.apiScopes.days': 'Dni',
+ 'settings.apiScopes.places': 'Miejsca',
+ 'settings.apiScopes.notes': 'Notatki dnia',
+ 'settings.apiScopes.reservations': 'Rezerwacje',
+ 'settings.apiScopes.accommodations': 'Noclegi',
+ 'settings.apiScopes.travellers': 'Kto jedzie',
+ 'settings.apiScopes.bucket-list': 'Lista marzeń',
+ 'settings.apiScopes.stats': 'Podsumowania',
'settings.apiKeys.title': 'Klucze API',
'settings.apiKeys.description': 'Klucze do publicznego API, aby inne oprogramowanie mogło odczytywać Twoje podróże. Tylko odczyt: klucz niczego nie zmieni ani nie usunie.',
'settings.apiKeys.create': 'Utwórz klucz',
diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts
index 8550d8243..c1bbc08d0 100644
--- a/shared/src/i18n/ru/settings.ts
+++ b/shared/src/i18n/ru/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Автоматически показывает на карте маршрут для каждого рейса, поезда и другого бронирования — без необходимости включать это отдельно для каждого элемента.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Что этот ключ может читать',
+ 'settings.apiScopes.hint':
+ 'Оставьте всё включённым для ключа, который должен видеть всё. Всё, что вы выключите, для этого ключа будет запрещено, а не просто опущено в ответе.',
+ 'settings.apiScopes.all': 'Всё',
+ 'settings.apiScopes.limited': '{count} из {total}',
+ 'settings.apiScopes.trips': 'Поездки',
+ 'settings.apiScopes.days': 'Дни',
+ 'settings.apiScopes.places': 'Места',
+ 'settings.apiScopes.notes': 'Заметки дня',
+ 'settings.apiScopes.reservations': 'Бронирования',
+ 'settings.apiScopes.accommodations': 'Проживание',
+ 'settings.apiScopes.travellers': 'Кто едет',
+ 'settings.apiScopes.bucket-list': 'Список желаний',
+ 'settings.apiScopes.stats': 'Итоги',
'settings.apiKeys.title': 'Ключи API',
'settings.apiKeys.description': 'Ключи для публичного API, чтобы другие программы могли читать ваши поездки. Только чтение: ключ ничего не изменит и не удалит.',
'settings.apiKeys.create': 'Создать ключ',
diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts
index 5b3fac127..0fe9c5b1c 100644
--- a/shared/src/i18n/sv/settings.ts
+++ b/shared/src/i18n/sv/settings.ts
@@ -549,6 +549,21 @@ const settings: TranslationStrings = {
'Visar automatiskt rutten för varje flyg, tåg och annan bokning på kartan, utan att du behöver aktivera det för varje bokning för sig.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Vad den här nyckeln får läsa',
+ 'settings.apiScopes.hint':
+ 'Lämna allt påslaget för en nyckel som ska se allt. Det du stänger av nekas för den här nyckeln, det utelämnas inte bara ur svaret.',
+ 'settings.apiScopes.all': 'Allt',
+ 'settings.apiScopes.limited': '{count} av {total}',
+ 'settings.apiScopes.trips': 'Resor',
+ 'settings.apiScopes.days': 'Dagar',
+ 'settings.apiScopes.places': 'Platser',
+ 'settings.apiScopes.notes': 'Dagsanteckningar',
+ 'settings.apiScopes.reservations': 'Bokningar',
+ 'settings.apiScopes.accommodations': 'Boende',
+ 'settings.apiScopes.travellers': 'Vilka som följer med',
+ 'settings.apiScopes.bucket-list': 'Önskelista',
+ 'settings.apiScopes.stats': 'Totalsummor',
'settings.apiKeys.title': 'API-nycklar',
'settings.apiKeys.description': 'Nycklar för det publika API:et, så att annan programvara kan läsa dina resor. Endast läsning: en nyckel kan inte ändra eller ta bort något.',
'settings.apiKeys.create': 'Skapa nyckel',
diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts
index d88a99bfc..38d769e3d 100644
--- a/shared/src/i18n/tr/settings.ts
+++ b/shared/src/i18n/tr/settings.ts
@@ -550,6 +550,21 @@ const settings: TranslationStrings = {
'Haritada her uçuş, tren ve diğer rezervasyonun rotasını, tek tek açmaya gerek kalmadan otomatik olarak gösterir.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Bu anahtar neleri okuyabilir',
+ 'settings.apiScopes.hint':
+ 'Her şeyi görmesi gereken bir anahtar için hepsini açık bırakın. Kapattığınız her şey bu anahtar için reddedilir, yalnızca yanıttan çıkarılmakla kalmaz.',
+ 'settings.apiScopes.all': 'Her şey',
+ 'settings.apiScopes.limited': '{total} içinden {count}',
+ 'settings.apiScopes.trips': 'Geziler',
+ 'settings.apiScopes.days': 'Günler',
+ 'settings.apiScopes.places': 'Yerler',
+ 'settings.apiScopes.notes': 'Gün notları',
+ 'settings.apiScopes.reservations': 'Rezervasyonlar',
+ 'settings.apiScopes.accommodations': 'Konaklama',
+ 'settings.apiScopes.travellers': 'Kimler geliyor',
+ 'settings.apiScopes.bucket-list': 'Dilek listesi',
+ 'settings.apiScopes.stats': 'Toplamlar',
'settings.apiKeys.title': 'API Anahtarları',
'settings.apiKeys.description': 'Genel API için anahtarlar, böylece başka yazılımlar gezilerini okuyabilir. Yalnızca okuma: bir anahtar hiçbir şeyi değiştiremez veya silemez.',
'settings.apiKeys.create': 'Anahtar oluştur',
diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts
index 62bcc7e8a..4534bf99e 100644
--- a/shared/src/i18n/uk/settings.ts
+++ b/shared/src/i18n/uk/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Автоматично показує на карті маршрут для кожного рейсу, поїзда та іншого бронювання — без потреби вмикати це окремо для кожного елемента.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Що цей ключ може читати',
+ 'settings.apiScopes.hint':
+ 'Залиште все увімкненим для ключа, який має бачити геть усе. Усе, що ви вимкнете, для цього ключа буде заборонено, а не просто пропущено у відповіді.',
+ 'settings.apiScopes.all': 'Усе',
+ 'settings.apiScopes.limited': '{count} з {total}',
+ 'settings.apiScopes.trips': 'Подорожі',
+ 'settings.apiScopes.days': 'Дні',
+ 'settings.apiScopes.places': 'Місця',
+ 'settings.apiScopes.notes': 'Нотатки дня',
+ 'settings.apiScopes.reservations': 'Бронювання',
+ 'settings.apiScopes.accommodations': 'Проживання',
+ 'settings.apiScopes.travellers': 'Хто їде',
+ 'settings.apiScopes.bucket-list': 'Список бажань',
+ 'settings.apiScopes.stats': 'Підсумки',
'settings.apiKeys.title': 'Ключі API',
'settings.apiKeys.description': 'Ключі для публічного API, щоб інші програми могли читати ваші подорожі. Лише читання: ключ нічого не змінить і не видалить.',
'settings.apiKeys.create': 'Створити ключ',
diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts
index f3d2ad0d2..334cd3b31 100644
--- a/shared/src/i18n/vi/settings.ts
+++ b/shared/src/i18n/vi/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Tự động hiển thị trên bản đồ tuyến đường của mỗi chuyến bay, tàu hỏa và đặt chỗ khác, không cần bật riêng từng mục.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Khóa này được phép đọc gì',
+ 'settings.apiScopes.hint':
+ 'Hãy bật tất cả cho khóa cần xem được mọi thứ. Bất cứ mục nào bạn tắt sẽ bị từ chối với khóa này, chứ không chỉ là bị lược khỏi câu trả lời.',
+ 'settings.apiScopes.all': 'Tất cả',
+ 'settings.apiScopes.limited': '{count} trên {total}',
+ 'settings.apiScopes.trips': 'Chuyến đi',
+ 'settings.apiScopes.days': 'Ngày',
+ 'settings.apiScopes.places': 'Địa điểm',
+ 'settings.apiScopes.notes': 'Ghi chú theo ngày',
+ 'settings.apiScopes.reservations': 'Đặt chỗ',
+ 'settings.apiScopes.accommodations': 'Chỗ ở',
+ 'settings.apiScopes.travellers': 'Ai cùng đi',
+ 'settings.apiScopes.bucket-list': 'Danh sách mong muốn',
+ 'settings.apiScopes.stats': 'Tổng hợp',
'settings.apiKeys.title': 'Khóa API',
'settings.apiKeys.description': 'Khóa cho API công khai, để phần mềm khác có thể đọc các chuyến đi của bạn. Chỉ đọc: khóa không thể thay đổi hay xóa bất cứ thứ gì.',
'settings.apiKeys.create': 'Tạo khóa',
diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts
index 6c2f6e5e4..2fc286d64 100644
--- a/shared/src/i18n/zh-TW/settings.ts
+++ b/shared/src/i18n/zh-TW/settings.ts
@@ -517,6 +517,21 @@ const settings: TranslationStrings = {
'settings.alwaysShowRoutesHint': '自動在地圖上顯示每個航班、火車及其他預訂的路線,不需要逐一手動開啟。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '這組金鑰可讀取的內容',
+ 'settings.apiScopes.hint':
+ '若希望這組金鑰能看到全部內容,就全部保持開啟。任何你關閉的項目都會對這組金鑰直接拒絕,而不只是從回應中省略。',
+ 'settings.apiScopes.all': '全部',
+ 'settings.apiScopes.limited': '{total} 項中的 {count} 項',
+ 'settings.apiScopes.trips': '旅行',
+ 'settings.apiScopes.days': '日期',
+ 'settings.apiScopes.places': '地點',
+ 'settings.apiScopes.notes': '每日備註',
+ 'settings.apiScopes.reservations': '訂位',
+ 'settings.apiScopes.accommodations': '住宿',
+ 'settings.apiScopes.travellers': '同行者',
+ 'settings.apiScopes.bucket-list': '願望清單',
+ 'settings.apiScopes.stats': '總計',
'settings.apiKeys.title': 'API 金鑰',
'settings.apiKeys.description': '公開 API 的金鑰,讓其他軟體讀取你的行程。唯讀:金鑰無法修改或刪除任何內容。',
'settings.apiKeys.create': '建立金鑰',
diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts
index a662db141..93df25e5c 100644
--- a/shared/src/i18n/zh/settings.ts
+++ b/shared/src/i18n/zh/settings.ts
@@ -513,6 +513,21 @@ const settings: TranslationStrings = {
'settings.alwaysShowRoutesHint': '自动在地图上显示每个航班、火车和其他预订的路线,无需逐个手动开启。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '此密钥可读取的内容',
+ 'settings.apiScopes.hint':
+ '如果希望这个密钥看到全部内容,就保持全部开启。任何被关闭的内容对该密钥都会被拒绝,而不只是不出现在结果里。',
+ 'settings.apiScopes.all': '全部',
+ 'settings.apiScopes.limited': '{total} 项中的 {count} 项',
+ 'settings.apiScopes.trips': '行程',
+ 'settings.apiScopes.days': '日程',
+ 'settings.apiScopes.places': '地点',
+ 'settings.apiScopes.notes': '每日备注',
+ 'settings.apiScopes.reservations': '预订',
+ 'settings.apiScopes.accommodations': '住宿',
+ 'settings.apiScopes.travellers': '同行人员',
+ 'settings.apiScopes.bucket-list': '愿望清单',
+ 'settings.apiScopes.stats': '统计总计',
'settings.apiKeys.title': 'API 密钥',
'settings.apiKeys.description': '公开 API 的密钥,让其他软件读取你的行程。只读:密钥无法修改或删除任何内容。',
'settings.apiKeys.create': '创建密钥',
diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts
index 534b17875..8c92e372e 100644
--- a/shared/src/public-api/public-api.schema.ts
+++ b/shared/src/public-api/public-api.schema.ts
@@ -206,6 +206,82 @@ export const publicApiTripListSchema = z.object({
});
export type PublicApiTripList = z.infer;
+/**
+ * What an integration key is allowed to read.
+ *
+ * These are **not** `include`. `include` picks which sections a response
+ * carries and any caller may ask for any of them; these decide what the key may
+ * see at all, and the server refuses the rest. The two were confused often
+ * enough that it is worth saying plainly: handing an integration a key because
+ * it wanted day notes used to hand it every trip in full.
+ *
+ * The sections mirror the `include` vocabulary so an integrator does not have
+ * to learn two lists, plus the two endpoints that are not a trip section:
+ * `bucket-list` (hangs off the user) and `stats` (aggregates everything, which
+ * is exactly why a narrow key must be able to exclude it).
+ */
+export const PUBLIC_API_SCOPES = [
+ 'trips',
+ 'days',
+ 'places',
+ 'notes',
+ 'reservations',
+ 'accommodations',
+ 'travellers',
+ 'bucket-list',
+ 'stats',
+] as const;
+export type PublicApiScope = (typeof PUBLIC_API_SCOPES)[number];
+
+/**
+ * How a key's access is decided.
+ *
+ * `all` is an explicit value rather than an absent list, because "no list means
+ * everything" is the kind of sentinel where a bug that drops the column grants
+ * full access instead of none. Every key minted before scopes existed is `all`,
+ * which is exactly what it was and still is.
+ */
+export const PUBLIC_API_SCOPE_MODES = ['all', 'limited'] as const;
+export const publicApiScopeModeSchema = z.enum(PUBLIC_API_SCOPE_MODES);
+export type PublicApiScopeMode = z.infer;
+
+/**
+ * What a key may read, resolved.
+ *
+ * `mode: 'all'` carries the full list rather than an empty one, so a consumer
+ * never has to reimplement "empty means everything" — the rule this type exists
+ * to get rid of.
+ */
+export const publicApiGrantSchema = z.object({
+ mode: publicApiScopeModeSchema,
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
+});
+export type PublicApiGrant = z.infer;
+
+/**
+ * Minting an integration key. A key created without `scopes` reads everything,
+ * which is what every key did before this existed — narrowing is opt-in, so no
+ * integration breaks the day the column ships.
+ */
+export const apiTokenCreateRequestSchema = z.object({
+ name: z.string().optional(),
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)).min(1).optional(),
+});
+export type ApiTokenCreateRequest = z.infer;
+
+/** A key as the settings list shows it. The key itself is never in here. */
+export const apiTokenSummarySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ token_prefix: z.string(),
+ created_at: z.string(),
+ last_used_at: z.string().nullable(),
+ scope_mode: publicApiScopeModeSchema,
+ /** The chosen sections; the full list when the mode is `all`. */
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
+});
+export type ApiTokenSummary = z.infer;
+
/**
* The trip the traveller most recently took, for a dashboard that wants to name it.
*
diff --git a/wiki/Public-API.md b/wiki/Public-API.md
index c10571cad..4cf2baf3d 100644
--- a/wiki/Public-API.md
+++ b/wiki/Public-API.md
@@ -19,7 +19,8 @@ It cannot write anything. An integration that reads your itinerary needs no abil
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.
+3. Choose **what the key may read.** Everything is selected by default. Anything you switch off is refused for that key.
+4. **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.
@@ -45,6 +46,21 @@ curl -H "X-API-Key: trek_your_key_here" \
Both are equivalent. Use whichever the other side offers.
+## What a key may read
+
+A key can be narrowed when you create it, to any subset of:
+
+`trips` · `days` · `places` · `notes` · `reservations` · `accommodations` · `travellers` · `bucket-list` · `stats`
+
+This is **not** the same as `include`. `include` chooses which sections a response carries and any key may ask for any of them; this decides what the key may see at all, and the server refuses the rest with a `403`. A key given only `days` and `notes` reads exactly that, however it asks.
+
+Two rules make it predictable:
+
+- **A section you named but may not read is a `403`**, with the missing one in `required_scope`. Silently dropping what you explicitly asked for would send you debugging your own correct code.
+- **A section you did not name is simply left out.** `include` defaults to everything, so a narrow key that omits `include` gets what it may have rather than an error for wanting sections it never mentioned.
+
+Keys created before this existed, and keys created with everything selected, read all of it — nothing that worked before stops working.
+
## Endpoints
### `GET /api/v1/trips`
@@ -220,6 +236,7 @@ A Homepage widget then needs no scripting:
|---|---|
| `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 |
+| `403` | the key is valid but not allowed to read what was asked for (`code: "API_SCOPE_FORBIDDEN"`, with the missing section in `required_scope`) |
| `404` | no such trip, or not one of yours |
| `429` | rate limit exceeded |
@@ -227,7 +244,7 @@ A Homepage widget then needs no scripting:
## 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.
+- **Writing.** Read-only. A key that can only read is a very different thing to hand to third-party software, and every integration asking for this surface so far wants to read.
- **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.