diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts
index d5c7a2a3..635e3f2c 100644
--- a/client/src/store/journeyStore.test.ts
+++ b/client/src/store/journeyStore.test.ts
@@ -1,6 +1,7 @@
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
+import { journeyApi } from '../api/client';
import { useJourneyStore } from './journeyStore';
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
useJourneyStore.setState({ current: detail });
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
- server.use(
- http.post('/api/journeys/entries/100/photos', () =>
- HttpResponse.json({ photos: [newPhoto] })
- )
- );
- const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
- expect(result).toHaveLength(1);
- expect(result[0].id).toBe(91);
+ // MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
+ // emit upload progress events, which hangs in jsdom+Node. Spy on the API
+ // layer directly so this test exercises store state management only.
+ const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
+ const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
+ expect(result.succeeded).toHaveLength(1);
+ expect(result.succeeded[0].id).toBe(91);
+ expect(result.failed).toHaveLength(0);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
expect(storedEntry?.photos).toHaveLength(2);
+ spy.mockRestore();
+ });
+
+ it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
+ const entry = buildEntry({ id: 100, photos: [] });
+ const detail = buildJourneyDetail({ id: 50, entries: [entry] });
+ useJourneyStore.setState({ current: detail });
+
+ server.use(
+ http.post('/api/journeys/entries/100/photos', () =>
+ HttpResponse.error()
+ )
+ );
+ const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
+ expect(result.succeeded).toHaveLength(0);
+ expect(result.failed).toHaveLength(1);
+ expect(result.failed[0]).toBe(file);
+ const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
+ expect(storedEntry?.photos).toHaveLength(0);
+ });
+
+ it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
+ const entry = buildEntry({ id: 100, photos: [] });
+ const detail = buildJourneyDetail({ id: 50, entries: [entry] });
+ useJourneyStore.setState({ current: detail });
+
+ const photo1 = buildPhoto({ id: 91, entry_id: 100 });
+ const photo2 = buildPhoto({ id: 92, entry_id: 100 });
+ let callCount = 0;
+ // Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
+ // Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
+ const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
+ callCount++;
+ if (callCount === 1) return { photos: [photo1] } as any;
+ throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
+ });
+ const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
+ const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
+ expect(result.succeeded).toHaveLength(1);
+ expect(result.succeeded[0].id).toBe(photo1.id);
+ expect(result.failed).toHaveLength(1);
+ const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
+ expect(storedEntry?.photos).toHaveLength(1);
+ void photo2; // referenced to avoid lint warning
+ spy.mockRestore();
});
// ── deletePhoto ──────────────────────────────────────────────────────────
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
index c2edfa69..279b581f 100644
--- a/client/src/store/journeyStore.ts
+++ b/client/src/store/journeyStore.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { journeyApi } from '../api/client'
+import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
export interface Journey {
id: number
@@ -121,8 +122,8 @@ interface JourneyState {
deleteEntry: (entryId: number) => Promise
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise
- uploadPhotos: (entryId: number, formData: FormData) => Promise
- uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise
+ uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
+ uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise
deletePhoto: (photoId: number) => Promise
@@ -237,32 +238,49 @@ export const useJourneyStore = create((set, get) => ({
}
},
- uploadPhotos: async (entryId, formData) => {
- const data = await journeyApi.uploadPhotos(entryId, formData)
- const photos = data.photos || []
- set(s => {
- if (!s.current) return s
- return {
- current: {
- ...s.current,
- entries: s.current.entries.map(e =>
- e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
- ),
- gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
- },
- }
- })
- return photos
+ uploadPhotos: async (entryId, files, cbs) => {
+ return uploadFilesResilient(
+ files,
+ async (file, opts) => {
+ const fd = new FormData()
+ fd.append('photos', file)
+ const data = await journeyApi.uploadPhotos(entryId, fd, opts)
+ const photos: JourneyPhoto[] = data.photos || []
+ const gallery: GalleryPhoto[] = data.gallery || []
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ entries: s.current.entries.map(e =>
+ e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
+ ),
+ gallery: [...(s.current.gallery || []), ...gallery],
+ },
+ }
+ })
+ return photos
+ },
+ { onProgress: cbs?.onProgress },
+ )
},
- uploadGalleryPhotos: async (journeyId, formData) => {
- const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
- const photos: GalleryPhoto[] = data.photos || []
- set(s => {
- if (!s.current || s.current.id !== journeyId) return s
- return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
- })
- return photos
+ uploadGalleryPhotos: async (journeyId, files, cbs) => {
+ return uploadFilesResilient(
+ files,
+ async (file, opts) => {
+ const fd = new FormData()
+ fd.append('photos', file)
+ const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
+ const photos: GalleryPhoto[] = data.photos || []
+ set(s => {
+ if (!s.current || s.current.id !== journeyId) return s
+ return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
+ })
+ return photos
+ },
+ { onProgress: cbs?.onProgress },
+ )
},
unlinkPhoto: async (entryId, journeyPhotoId) => {
diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts
index 1f894792..bc1b5d74 100644
--- a/client/src/utils/dayMerge.test.ts
+++ b/client/src/utils/dayMerge.test.ts
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 },
]
- it('excludes non-transport types', () => {
+ it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
+ it('includes tour booking on the correct day', () => {
+ const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
+ expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
+ })
+
+ it('includes restaurant, event, and other bookings by day_id', () => {
+ const reservations = [
+ { id: 30, type: 'restaurant', day_id: 2 },
+ { id: 31, type: 'event', day_id: 2 },
+ { id: 32, type: 'other', day_id: 2 },
+ ]
+ expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
+ })
+
it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index 0889ace2..d465139c 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => {
- if (!TRANSPORT_TYPES.has(r.type)) return false
+ if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts
new file mode 100644
index 00000000..08c7ab73
--- /dev/null
+++ b/client/src/utils/formatters.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest'
+import { splitReservationDateTime } from './formatters'
+
+describe('splitReservationDateTime', () => {
+ it('parses full ISO datetime', () => {
+ expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
+ })
+
+ it('parses full datetime with seconds', () => {
+ expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
+ })
+
+ it('parses date-only string', () => {
+ expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
+ })
+
+ it('parses bare HH:MM (new dateless format)', () => {
+ expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
+ })
+
+ it('parses bare single-digit hour time', () => {
+ expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
+ })
+
+ it('handles legacy malformed T-prefixed time ("T10:00")', () => {
+ expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
+ })
+
+ it('returns null date for T-prefixed without valid date', () => {
+ const result = splitReservationDateTime('T23:59')
+ expect(result.date).toBeNull()
+ expect(result.time).toBe('23:59')
+ })
+
+ it('returns nulls for null input', () => {
+ expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for undefined input', () => {
+ expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for empty string', () => {
+ expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for unrecognized string', () => {
+ expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
+ })
+})
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 7a6c01a2..d586f1cb 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
} catch { return timeStr }
}
+export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
+ if (!value) return { date: null, time: null }
+ const isoDate = /^\d{4}-\d{2}-\d{2}$/
+ if (value.includes('T')) {
+ const [d, t] = value.split('T')
+ return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
+ }
+ if (isoDate.test(value)) return { date: value, time: null }
+ if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
+ return { date: null, time: null }
+}
+
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
diff --git a/client/src/utils/uploadQueue.ts b/client/src/utils/uploadQueue.ts
new file mode 100644
index 00000000..2b949bec
--- /dev/null
+++ b/client/src/utils/uploadQueue.ts
@@ -0,0 +1,106 @@
+import type { AxiosProgressEvent } from 'axios'
+
+export interface UploadProgress {
+ done: number
+ total: number
+ failed: number
+ percent: number
+}
+
+export interface ResilientResult {
+ succeeded: T[]
+ failed: File[]
+}
+
+export interface UploadOpts {
+ onUploadProgress: (e: AxiosProgressEvent) => void
+ idempotencyKey: string
+}
+
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+function isRetryable(err: unknown): boolean {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const status = (err as { response?: { status?: number } }).response?.status
+ if (status !== undefined && status >= 400 && status < 500) return false
+ }
+ return true
+}
+
+export async function uploadFilesResilient(
+ files: File[],
+ uploadOne: (file: File, opts: UploadOpts) => Promise,
+ cbs?: {
+ concurrency?: number
+ retries?: number
+ onProgress?: (p: UploadProgress) => void
+ onUploaded?: (items: T[]) => void
+ },
+): Promise> {
+ const concurrency = cbs?.concurrency ?? 3
+ const maxRetries = cbs?.retries ?? 2
+
+ const totalBytes = files.reduce((s, f) => s + f.size, 0)
+ const loadedMap = new Map()
+ let doneCount = 0
+ let failedCount = 0
+
+ const emitProgress = () => {
+ if (!cbs?.onProgress) return
+ const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
+ const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
+ cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
+ }
+
+ const succeeded: T[] = []
+ const failedFiles: File[] = []
+
+ let idx = 0
+
+ async function worker() {
+ while (true) {
+ const i = idx++
+ if (i >= files.length) break
+ const file = files[i]
+ const idempotencyKey = crypto.randomUUID()
+ loadedMap.set(i, 0)
+
+ let items: T[] | null = null
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ if (attempt > 0) await sleep(400 * attempt)
+ try {
+ items = await uploadOne(file, {
+ idempotencyKey,
+ onUploadProgress: (e) => {
+ loadedMap.set(i, e.loaded)
+ emitProgress()
+ },
+ })
+ break
+ } catch (err) {
+ if (!isRetryable(err) || attempt === maxRetries) {
+ items = null
+ break
+ }
+ }
+ }
+
+ if (items !== null) {
+ succeeded.push(...items)
+ cbs?.onUploaded?.(items)
+ loadedMap.set(i, file.size)
+ doneCount++
+ } else {
+ failedFiles.push(file)
+ loadedMap.set(i, 0)
+ failedCount++
+ }
+ emitProgress()
+ }
+ }
+
+ const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
+ await Promise.all(workers)
+
+ return { succeeded, failed: failedFiles }
+}
diff --git a/client/vite.config.js b/client/vite.config.js
index 0d85df86..ea017597 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -90,7 +90,7 @@ export default defineConfig({
],
build: {
sourcemap: false,
- modulePreload: { polyfill: false },
+ modulePreload: { polyfill: true },
},
server: {
port: 5173,
diff --git a/server/src/app.ts b/server/src/app.ts
index 45d17b7d..bff3d1d3 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -397,7 +397,7 @@ export function createApp(): express.Application {
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
- grant_types_supported: ['authorization_code', 'refresh_token'],
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index bdd14379..63eeb7b8 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -2229,6 +2229,14 @@ function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
},
+ // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
+ // clients to skip the browser consent flow entirely and obtain tokens directly
+ // via client_id + client_secret. Flag is immutable after creation so existing
+ // authorization-code clients are not silently upgraded.
+ () => {
+ try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
+ catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts
index e6b22a05..83ff881c 100644
--- a/server/src/mcp/tools/days.ts
+++ b/server/src/mcp/tools/days.ts
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
server.registerTool(
'create_place_accommodation',
{
- description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
+ description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
+ price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
+ async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
const run = db.transaction(() => {
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation };
});
diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts
index 451edaf2..a5bdcfe4 100644
--- a/server/src/mcp/tools/places.ts
+++ b/server/src/mcp/tools/places.ts
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool(
'create_place',
{
- description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
+ description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
+ price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
+ async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool(
'create_and_assign_place',
{
- description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
+ description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'),
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
+ price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
+ async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
diff --git a/server/src/mcp/tools/reservations.ts b/server/src/mcp/tools/reservations.ts
index 1a2acef5..9c8825fb 100644
--- a/server/src/mcp/tools/reservations.ts
+++ b/server/src/mcp/tools/reservations.ts
@@ -6,6 +6,7 @@ import {
createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions,
} from '../../services/reservationService';
+import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import {
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
server.registerTool(
'create_reservation',
{
- description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
+ description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
+ price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
+ budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
+ async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined;
+ const metadata = price != null ? { price: String(price) } : undefined;
+
const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
+ metadata,
});
if (accommodationCreated) {
safeBroadcast(tripId, 'accommodation:created', {});
}
+
+ if (price != null && price > 0) {
+ const item = linkBudgetItemToReservation(tripId, reservation.id, {
+ name: title,
+ category: budget_category || type,
+ total_price: price,
+ });
+ safeBroadcast(tripId, 'budget:created', { item });
+ }
+
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts
index 535ab7bc..d2cf022e 100644
--- a/server/src/mcp/tools/transports.ts
+++ b/server/src/mcp/tools/transports.ts
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
import {
createReservation, deleteReservation, getReservation, updateReservation,
} from '../../services/reservationService';
+import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
server.registerTool(
'create_transport',
{
- description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
+ description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
+ price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
+ budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
+ async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
+ const meta: Record = { ...(metadata ?? {}) };
+ if (price != null) meta.price = String(price);
+
const { reservation } = createReservation(tripId, {
title,
type,
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
day_id: start_day_id,
end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending',
- metadata,
+ metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints,
needs_review,
});
+
+ if (price != null && price > 0) {
+ const item = linkBudgetItemToReservation(tripId, reservation.id, {
+ name: title,
+ category: budget_category || type,
+ total_price: price,
+ });
+ safeBroadcast(tripId, 'budget:created', { item });
+ }
+
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts
index d7964329..e5666dc1 100644
--- a/server/src/routes/oauth.ts
+++ b/server/src/routes/oauth.ts
@@ -10,6 +10,7 @@ import {
consumeAuthCode,
saveConsent,
issueTokens,
+ issueClientCredentialsToken,
refreshTokens,
revokeToken,
verifyPKCE,
@@ -24,6 +25,7 @@ import {
AuthorizeParams,
} from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
+import { getMcpSafeUrl } from '../services/notifications';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.json(result.tokens);
}
+ // ---- client_credentials grant ----
+ if (grant_type === 'client_credentials') {
+ if (!client_secret) {
+ return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
+ }
+
+ const client = authenticateClient(client_id, client_secret);
+ if (!client) {
+ logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
+ writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
+ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
+ }
+
+ // Public clients and DCR-anonymous clients are ineligible for client_credentials.
+ if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
+ writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
+ return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
+ }
+
+ // Scope: use requested subset or fall back to all allowed scopes.
+ const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
+ let grantedScopes: string[];
+ if (body.scope) {
+ const requested = body.scope.split(' ').filter(Boolean);
+ const invalid = requested.filter(s => !allowedScopes.includes(s));
+ if (invalid.length > 0) {
+ return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
+ }
+ grantedScopes = requested;
+ } else {
+ grantedScopes = allowedScopes;
+ }
+
+ // Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
+ // token passes audience binding in mcp/index.ts without extra configuration.
+ const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
+
+ const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
+ writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
+ return res.json(tokens);
+ }
+
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
- const { name, redirect_uris, allowed_scopes } = req.body as {
+ const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
name: string;
- redirect_uris: string[];
+ redirect_uris?: string[];
allowed_scopes: string[];
+ allows_client_credentials?: boolean;
};
- const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
+ const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 52cfdc63..48e9ef03 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -13,7 +13,7 @@ import {
updateReservation,
deleteReservation,
} from '../services/reservationService';
-import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
+import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
const router = express.Router({ mergeParams: true });
@@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Auto-create budget entry if price was provided
if (create_budget_entry && create_budget_entry.total_price > 0) {
try {
- const budgetItem = createBudgetItem(tripId, {
+ const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price,
});
- db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
- budgetItem.reservation_id = reservation.id;
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts
index f98d5d9e..7c5934c4 100644
--- a/server/src/services/budgetService.ts
+++ b/server/src/services/budgetService.ts
@@ -96,6 +96,17 @@ export function createBudgetItem(
return item;
}
+export function linkBudgetItemToReservation(
+ tripId: string | number,
+ reservationId: number,
+ data: { name: string; category?: string; total_price: number },
+) {
+ const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
+ db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
+ item.reservation_id = reservationId;
+ return item;
+}
+
export function updateBudgetItem(
id: string | number,
tripId: string | number,
diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts
index 01864156..2e782f97 100644
--- a/server/src/services/oauthService.ts
+++ b/server/src/services/oauthService.ts
@@ -60,6 +60,7 @@ interface OAuthClientRow {
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
+ allows_client_credentials: number; // 0 | 1
}
interface OAuthTokenRow {
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
export function listOAuthClients(userId: number): Record[] {
const rows = db.prepare(
- 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
+ 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
+ allows_client_credentials: Boolean(r.allows_client_credentials),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
@@ -132,11 +134,12 @@ export function createOAuthClient(
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
- options?: { isPublic?: boolean; createdVia?: string },
+ options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
- if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
+ const isMachineClient = Boolean(options?.allowsClientCredentials);
+ if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
@@ -164,7 +167,8 @@ export function createOAuthClient(
if (count >= 500) return { error: 'server_error', status: 503 };
}
- const isPublic = options?.isPublic ?? false;
+ // Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
+ const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
@@ -173,14 +177,14 @@ export function createOAuthClient(
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare(
- 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
- ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
+ 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
const row = db.prepare(
- 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
+ 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
- writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
+ writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return {
client: {
@@ -192,6 +196,7 @@ export function createOAuthClient(
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
+ allows_client_credentials: Boolean(row.allows_client_credentials),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
@@ -330,6 +335,43 @@ export function issueTokens(
};
}
+// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
+// Used exclusively for the client_credentials grant. A random opaque hash is
+// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
+// can never be presented as a valid refresh token (same precedent as public
+// client secret hashes stored in client_secret_hash).
+export function issueClientCredentialsToken(
+ clientId: string,
+ userId: number,
+ scopes: string[],
+ audience: string,
+): {
+ access_token: string;
+ token_type: 'Bearer';
+ expires_in: number;
+ scope: string;
+} {
+ const rawAccess = generateAccessToken();
+ const accessHash = hashToken(rawAccess);
+ const placeholderHash = randomBytes(32).toString('hex');
+
+ const now = new Date();
+ const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
+
+ db.prepare(`
+ INSERT INTO oauth_tokens
+ (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
+
+ return {
+ access_token: rawAccess,
+ token_type: 'Bearer',
+ expires_in: ACCESS_TOKEN_TTL_S,
+ scope: scopes.join(' '),
+ };
+}
+
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts
index c977a260..83ad7171 100644
--- a/server/src/services/tripService.ts
+++ b/server/src/services/tripService.ts
@@ -506,6 +506,11 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Reservations as events
for (const r of reservations) {
if (!r.reservation_time) continue;
+ // Skip time-only values (no calendar date — occurs on relative "Day N" trips)
+ const hasDate = r.reservation_time.includes('T')
+ ? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
+ : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
+ if (!hasDate) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts
index 5afb6104..beeebd9b 100644
--- a/server/tests/integration/oauth.test.ts
+++ b/server/tests/integration/oauth.test.ts
@@ -63,7 +63,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
-import { createOAuthClient, createAuthCode } from '../../src/services/oauthService';
+import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
const app: Application = createApp();
@@ -1285,4 +1285,141 @@ describe('C3 — Refresh token replay detection', () => {
expect(t4.status).toBe(400);
expect(t4.body.error).toBe('invalid_grant');
});
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// POST /oauth/token — client_credentials grant
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('POST /oauth/token — client_credentials grant', () => {
+ it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.access_token).toBeDefined();
+ expect(res.body.token_type).toBe('Bearer');
+ expect(typeof res.body.expires_in).toBe('number');
+ expect(res.body.scope).toBe('trips:read');
+ expect(res.body.refresh_token).toBeUndefined();
+ });
+
+ it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(200);
+ const info = getUserByAccessToken(res.body.access_token);
+ expect(info).not.toBeNull();
+ expect(info!.user.id).toBe(user.id);
+ expect(info!.scopes).toEqual(['trips:read']);
+ });
+
+ it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: 'trekcs_wrong',
+ });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('invalid_client');
+ });
+
+ it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('invalid_client');
+ });
+
+ it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('unauthorized_client');
+ });
+
+ it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ scope: 'trips:read',
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.scope).toBe('trips:read');
+ });
+
+ it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ scope: 'places:write',
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('invalid_scope');
+ });
+
+ it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ expect(r.error).toBeUndefined();
+ expect(r.client).toBeDefined();
+ expect(r.client!.allows_client_credentials).toBe(true);
+ expect((r.client!.redirect_uris as string[]).length).toBe(0);
+ expect(r.client!.client_secret).toBeDefined();
+ });
});
\ No newline at end of file
diff --git a/wiki/MCP-Overview.md b/wiki/MCP-Overview.md
index 2f3c2026..79922452 100644
--- a/wiki/MCP-Overview.md
+++ b/wiki/MCP-Overview.md
@@ -18,6 +18,16 @@ Once connected, an AI assistant can work with your TREK data in a single convers
Changes made through MCP are broadcast to all connected clients in real-time — exactly like changes made in the web UI.
+## Authentication options
+
+| Use case | Method |
+|---|---|
+| Interactive client (Claude.ai, Cursor, VS Code…) | OAuth 2.1 with browser consent — TREK issues tokens after you approve scopes in a consent screen |
+| AI agent or script running unattended | Machine client (client_credentials) — token obtained directly via `client_id` + `client_secret`, no browser ever opened |
+| Legacy setups | Static API token — deprecated, full access, no scopes |
+
+See [MCP-Setup](MCP-Setup) for step-by-step instructions for each method.
+
## Requirements
- **MCP addon enabled** — an administrator must enable the MCP addon (`mcp`) from the Admin Panel before the `/mcp` endpoint becomes available and the MCP section appears in user settings.
diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md
index 40b1e9d7..c26540ac 100644
--- a/wiki/MCP-Setup.md
+++ b/wiki/MCP-Setup.md
@@ -1,6 +1,6 @@
# MCP Setup
-This page explains how to connect an AI assistant to your TREK instance. TREK supports two authentication methods: OAuth 2.1 (recommended) and static API tokens (deprecated).
+This page explains how to connect an AI assistant to your TREK instance. TREK supports three authentication methods: OAuth 2.1 with browser consent (recommended for interactive clients), machine clients with no browser login (recommended for AI agents and scripts), and static API tokens (deprecated).
@@ -23,25 +23,12 @@ Claude.ai (web) supports native MCP connections — no JSON config file required
### Claude Desktop
-Claude Desktop connects via `mcp-remote`. After creating an OAuth client using the **Claude Desktop** preset (redirect URI: `http://localhost`), add the following to your Claude Desktop config:
+Claude Desktop supports native MCP connections — no JSON config file required:
-```json
-{
- "mcpServers": {
- "trek": {
- "command": "npx",
- "args": [
- "mcp-remote",
- "https:///mcp",
- "--static-oauth-client-info",
- "{\"client_id\": \"\", \"client_secret\": \"\"}"
- ]
- }
- }
-}
-```
-
-When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow.
+1. In TREK, go to **Settings → Integrations → MCP → OAuth Clients** and click **Create**.
+2. Select the **Claude Desktop** preset. This fills in the redirect URI and a default scope set.
+3. Give the client a name, adjust scopes if needed, and save. Copy the client ID and client secret — the secret is shown only once.
+4. In Claude Desktop, open Settings → MCP and add a new server using your TREK URL (`https:///mcp`). Claude Desktop will open your browser to complete the OAuth consent flow.
### Cursor, VS Code, Windsurf, and Zed
@@ -99,9 +86,34 @@ Create a client in TREK using the appropriate preset (Cursor, VS Code, Windsurf,
Each user can have up to **10 OAuth clients**.
-## Option B: Static API token (deprecated)
+## Option B: Machine client — no browser login (for AI agents and scripts)
-> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1.
+Use this when your AI agent or automation script needs to authenticate silently without any browser interaction. Instead of going through an OAuth consent flow, the client exchanges a `client_id` and `client_secret` directly for an access token ([RFC 6749 §4.4 — Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
+
+**Why this exists:** browser-based OAuth flows break when an AI agent runs unattended. The agent may fire multiple concurrent token refreshes, causing replay detection to invalidate the session and open browser windows. Machine clients sidestep this entirely — there is no refresh token and no rotation race.
+
+**How it works:** the token acts as its owner (the user who created the client), scoped to the permissions chosen at creation. All TREK permission checks still apply — the AI agent can only access what you can access, narrowed further to the selected scopes.
+
+### Create a machine client
+
+1. Go to **Settings → Integrations → MCP → OAuth Clients** and click **New Client**.
+2. Tick **Machine client (no browser login)**. The redirect URI field disappears — machine clients don't need one.
+3. Give it a name, select scopes, and click **Register Client**.
+4. Copy the `client_id` and `client_secret` shown — the secret is displayed only once.
+
+### How token management works
+
+Your AI client uses the `client_id` and `client_secret` to request a token directly from TREK (`POST /oauth/token` with `grant_type=client_credentials`). Tokens are valid for 1 hour. When one expires, the client requests a new one silently — no browser window, no user action, no consent screen. This is handled entirely by the client.
+
+### Who should use this
+
+Machine clients are designed for **AI agent frameworks and custom MCP client implementations** that can call the token endpoint themselves and handle renewal programmatically. TREK advertises `client_credentials` in its OAuth discovery document (`/.well-known/oauth-authorization-server`), so any compliant client can discover and use it automatically.
+
+> **`mcp-remote` users:** `mcp-remote` implements the browser-based `authorization_code` flow only — it does not support `client_credentials`. If you use `mcp-remote`, stick with Option A and use the preset for your client. The machine client option is not applicable.
+
+## Option C: Static API token (deprecated)
+
+> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1 or machine clients.
Static tokens grant full access to all tools and resources with no scope restrictions. Sessions using a static token will receive deprecation warnings in the AI client on every tool call.
@@ -129,11 +141,12 @@ Each user can create up to **10 static tokens**.
## Authentication reference
-| Method | Token prefix | Access level | Expiry |
-|---|---|---|---|
-| OAuth 2.1 access token | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
-| OAuth client secret | `trekcs_` | Used during OAuth registration | No expiry (revoke via UI) |
-| Static API token | `trek_` | Full access | No expiry — **deprecated** |
+| Method | Grant | Token prefix | Access level | Expiry |
+|---|---|---|---|---|
+| OAuth 2.1 — browser consent | `authorization_code` | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
+| Machine client — no browser | `client_credentials` | `trekoa_` | Scoped (per-client), acts as owner | 1 hour; re-request silently, no refresh token |
+| OAuth client secret | — | `trekcs_` | Used to authenticate the client at the token endpoint | No expiry (revoke via UI) |
+| Static API token | — | `trek_` | Full access | No expiry — **deprecated** |
## Related