merge: resolve conflicts with dev, fix 7 Snyk security issues

- Resolve translation conflicts (keep both journey + OAuth scope keys)
- Resolve migrations.ts (dev OAuth migrations + journey migrations)
- Fix hono directory traversal, response splitting, input validation (CVE-2026-39407/08/09/10)
- Fix @hono/node-server directory traversal (CVE-2026-39406)
- Fix nodemailer CRLF injection (upgrade to 8.0.5)
This commit is contained in:
Maurice
2026-04-11 19:11:21 +02:00
121 changed files with 13475 additions and 2499 deletions
+11
View File
@@ -0,0 +1,11 @@
export const ADDON_IDS = {
MCP: 'mcp',
PACKING: 'packing',
BUDGET: 'budget',
DOCUMENTS: 'documents',
VACAY: 'vacay',
ATLAS: 'atlas',
COLLAB: 'collab',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
+9 -1
View File
@@ -32,6 +32,7 @@ import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
@@ -207,7 +208,7 @@ export function createApp(): express.Application {
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
@@ -216,6 +217,7 @@ export function createApp(): express.Application {
label: string;
input_type: string;
placeholder?: string | null;
hint?: string | null;
required: number;
secret: number;
settings_key?: string | null;
@@ -245,6 +247,7 @@ export function createApp(): express.Application {
label: f.label,
input_type: f.input_type,
placeholder: f.placeholder || '',
hint: f.hint || null,
required: !!f.required,
secret: !!f.secret,
settings_key: f.settings_key || null,
@@ -269,6 +272,11 @@ export function createApp(): express.Application {
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
app.use('/', oauthPublicRouter);
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
app.use('/api/oauth', oauthApiRouter);
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
+122 -2
View File
@@ -19,7 +19,8 @@ function runMigrations(db: Database.Database): void {
}
}
const migrations: Array<() => void> = [
type Migration = (() => void) | { raw: () => void };
const migrations: Migration[] = [
() => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
() => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
() => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
@@ -1316,13 +1317,132 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
},
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id);
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, user_id)
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
access_token_expires_at DATETIME NOT NULL,
refresh_token_expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
`);
},
// Migration: Refresh-token rotation chain tracking for replay detection
() => {
db.exec(`
ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
`);
},
// Migration: Public client support for browser-initiated dynamic registration (DCR)
() => {
db.exec(`
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
`);
},
// Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients
// (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction)
{
raw: () => {
db.exec('PRAGMA foreign_keys = OFF');
try {
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients_new (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_public INTEGER NOT NULL DEFAULT 0,
created_via TEXT NOT NULL DEFAULT 'settings_ui'
)
`);
db.exec(`INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`);
db.exec(`DROP TABLE oauth_clients`);
db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`);
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`);
})();
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
},
},
// Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos
() => {
const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'hint')) {
db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`);
}
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3)
`);
db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`);
db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`);
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4)
`);
db.exec(`
UPDATE photo_provider_fields
SET hint = 'providerUrlHintSynology'
WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url'
`);
},
];
if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
try {
db.transaction(() => migrations[i]())();
const migration = migrations[i];
if (typeof migration === 'function') {
db.transaction(migration)();
} else {
migration.raw();
}
} catch (err) {
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
process.exit(1);
+1
View File
@@ -245,6 +245,7 @@ function createTables(db: Database.Database): void {
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
settings_key TEXT,
+9 -7
View File
@@ -116,15 +116,17 @@ function seedAddons(db: Database.Database): void {
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
{ provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 },
];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
}
console.log('Default addons seeded');
} catch (err: unknown) {
+185 -48
View File
@@ -4,37 +4,113 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
import { getUserByAccessToken } from '../services/oauthService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog';
interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
lastActivity: number;
}
export { revokeUserSessions, revokeUserSessionsForClient };
const sessions = new Map<string, McpSession>();
// ---------------------------------------------------------------------------
// Base instructions injected into every MCP session via the initialize response.
// Claude and other clients use these as system-level context before any tool call.
// Keep this actionable and concise — vague prose doesn't help the model.
// ---------------------------------------------------------------------------
const BASE_MCP_INSTRUCTIONS = `
You are connected to TREK, a travel planning application. Below is a compact reference of the data model, key workflows, and behavioral rules you must follow.
## Data model
- **Trip** — top-level container. Has dates, currency, members (owner + collaborators), and optional add-ons.
- **Day** — one calendar day within a trip (YYYY-MM-DD). Days are generated automatically when a trip is created with start/end dates.
- **Place** — a point of interest (POI) stored in the trip's place pool. A place is NOT on the itinerary until it is assigned to a day.
- **Assignment** — links a Place to a Day (ordered, with optional start/end time). This is what builds the daily itinerary.
- **Accommodation** — a hotel or rental linked to a Place and a check-in/check-out day range.
- **Reservation** — a booking record (flight, train, restaurant, etc.) with confirmation details, linked to a day.
- **Day note** — a free-text annotation attached to a day (with optional time label and emoji icon).
- **Budget item** — an expense entry for a trip (amount, category, payer, split between members).
- **Packing item** — a checklist entry grouped into bags and categories.
- **Todo** — a task (not packing-specific) attached to a trip, ordered and togglable.
- **Tag** — a label that can be applied to places for filtering.
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
- **Atlas** — global travel journal: bucket list, visited countries and regions.
- **Vacay** — vacation-day planner that tracks leave across team members and years.
## Key workflows
**Discovering trips:** Always call \`list_trips\` first when no trip ID has been provided. Never assume a trip ID.
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result.
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
**Creating an accommodation:** A place must exist in the trip first. Create the place (or reuse an existing one), then call \`create_accommodation\` with that \`place_id\` and the \`start_day_id\`/\`end_day_id\`.
**Reordering:** Assignments, todos, packing items, and reservations all support positional reordering via dedicated reorder tools. Always read the current order from \`get_trip_summary\` before reordering.
## Access rules
- The authenticated user can only access trips they own or are a member of. Never guess at trip IDs.
- Only the trip owner can delete the trip, add members, or remove members.
- Deleting a place removes all of its day assignments as well — warn the user before doing this.
- Trips created via MCP are capped at 90 days.
## Dates and times
- All dates use ISO format: **YYYY-MM-DD**.
- Times are strings like **"09:00"** or **"14:30"** (24-hour). Pass \`null\` to clear a time.
- When displaying dates to users, use a friendly human-readable format (e.g. "Mon, Apr 14").
## Add-on features
The following features are optional and may not be available on every TREK instance. Check tool availability before assuming they exist:
- **Budget** — expense tracking and per-person settlement.
- **Packing** — checklist with bags, categories, and templates.
- **Collab** — shared notes, polls, and chat messages for group trips.
- **Atlas** — bucket list and visited-country/region tracking.
- **Vacay** — team vacation-day planner with public holiday integration.
## Behavioral rules
- Prefer \`get_trip_summary\` over individual list tools when you need a full picture — it is one call instead of many.
- Use \`search_place\` before \`create_place\` so the app gets structured POI data (coordinates, address, opening hours). Do not skip this step.
- When the user asks to "add X to day Y", resolve both the place (search + create if needed) and the day ID before calling \`assign_place_to_day\`.
- Do not batch destructive operations (delete trip, delete day, delete place) without explicit user confirmation for each.
- Present budget amounts with the trip's currency. Use \`get_trip_summary\` to read the currency field.
- For group trips, always check member IDs via \`list_trip_members\` before calling tools that require a \`userId\` (e.g. budget splits, assignment participants).
`.trim();
const STATIC_TOKEN_DEPRECATION_NOTICE =
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
'The actual tool result follows — answer the user\'s question as well.';
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 20;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 300; // requests per minute per user
interface RateLimitEntry {
count: number;
windowStart: number;
}
const rateLimitMap = new Map<number, RateLimitEntry>();
const rateLimitMap = new Map<string, RateLimitEntry>();
function isRateLimited(userId: number): boolean {
function isRateLimited(userId: number, clientId: string | null): boolean {
const key = `${userId}:${clientId ?? 'native'}`;
const now = Date.now();
const entry = rateLimitMap.get(userId);
const entry = rateLimitMap.get(key);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(userId, { count: 1, windowStart: now });
rateLimitMap.set(key, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
@@ -62,43 +138,83 @@ const sessionSweepInterval = setInterval(() => {
}
}
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [uid, entry] of rateLimitMap) {
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
for (const [key, entry] of rateLimitMap) {
if (entry.windowStart < rateCutoff) rateLimitMap.delete(key);
}
if (cleaned > 0 || sessions.size > 0) {
console.log(`[MCP] Session sweep: cleaned ${cleaned}, active ${sessions.size}`);
}
}, 10 * 60 * 1000); // sweep every 10 minutes
}, 60 * 1000); // sweep every 1 minute
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function verifyToken(authHeader: string | undefined): User | null {
const token = authHeader && authHeader.split(' ')[1];
if (!token) return null;
interface VerifyTokenResult {
user: User;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes: string[] | null;
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
clientId: string | null;
isStaticToken: boolean;
}
// Long-lived MCP API token (trek_...)
if (token.startsWith('trek_')) {
return verifyMcpToken(token);
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (!authHeader) return null;
// M8: strictly require "Bearer" scheme (RFC 6750)
const spaceIdx = authHeader.indexOf(' ');
if (spaceIdx === -1) return null;
const scheme = authHeader.slice(0, spaceIdx);
const token = authHeader.slice(spaceIdx + 1);
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
// OAuth 2.1 access token (trekoa_...)
if (token.startsWith('trekoa_')) {
const result = getUserByAccessToken(token);
if (!result) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
}
// Short-lived JWT
return verifyJwtToken(token);
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: true };
}
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = verifyJwtToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: false };
}
function logToolCallAudit(req: Request, userId: number, clientId: string | null): void {
const body = req.body as Record<string, unknown> | undefined;
if (body?.method !== 'tools/call') return;
const toolName = (body?.params as Record<string, unknown> | undefined)?.name;
if (typeof toolName !== 'string') return;
writeAudit({
userId,
action: 'mcp.tool_call',
resource: toolName,
details: { clientId: clientId ?? 'native' },
ip: getClientIp(req),
});
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
if (!isAddonEnabled('mcp')) {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
const user = verifyToken(req.headers['authorization']);
if (!user) {
const tokenResult = verifyToken(req.headers['authorization']);
if (!tokenResult) {
res.status(401).json({ error: 'Access token required' });
return;
}
const { user, scopes, clientId, isStaticToken } = tokenResult;
if (isRateLimited(user.id)) {
if (isRateLimited(user.id, clientId)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
return;
}
@@ -116,7 +232,12 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
if (session.clientId !== clientId) {
res.status(403).json({ error: 'Session was created with a different OAuth client' });
return;
}
session.lastActivity = Date.now();
logToolCallAudit(req, user.id, clientId);
try {
await session.transport.handleRequest(req, res, req.body);
} catch (err) {
@@ -140,49 +261,65 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
}
// Create a new per-user MCP server and session
const server = new McpServer({
name: 'TREK MCP',
version: '1.0.0',
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
const server = new McpServer(
{
name: 'TREK MCP',
version: '1.0.0',
},
});
registerResources(server, user.id);
registerTools(server, user.id);
{
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
}
);
// Per-session closure: fires the deprecation notice once, on the first tool call.
// Tool results are the only mechanism Claude reliably surfaces to the user;
// the instructions field is only background context and won't trigger a proactive warning.
let _noticeEmitted = false;
const getDeprecationNotice = (): string | null => {
if (!isStaticToken || _noticeEmitted) return null;
_noticeEmitted = true;
return STATIC_TOKEN_DEPRECATION_NOTICE;
};
registerResources(server, user.id, scopes);
registerTools(server, user.id, scopes, isStaticToken, getDeprecationNotice);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
console.log(`[MCP] Session ${sid} created for user ${user.id}. Active sessions: ${sessions.size}`);
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
},
onsessionclosed: (sid) => {
sessions.delete(sid);
},
});
logToolCallAudit(req, user.id, clientId);
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error', detail: String(err) });
res.status(500).json({ error: 'Internal MCP error' });
}
}
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
/** Invalidate all active MCP sessions (call when addon state changes so sessions re-create with updated tools). */
export function invalidateMcpSessions(): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
console.log('[MCP] All sessions invalidated due to addon state change');
}
/** Close all active MCP sessions (call during graceful shutdown). */
+35 -37
View File
@@ -9,12 +9,13 @@ import { listReservations } from '../services/reservationService';
import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listItems as listTodoItems } from '../services/todoService';
import { listFiles } from '../services/fileService';
import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canRead, canReadTrips } from './scopes';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -31,6 +32,16 @@ function accessDenied(uri: string) {
};
}
function scopeDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
}],
};
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
@@ -41,9 +52,9 @@ function jsonContent(uri: string, data: unknown) {
};
}
export function registerResources(server: McpServer, userId: number): void {
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
// List all accessible trips
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
@@ -54,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Single trip detail
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
@@ -67,7 +78,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Days with assigned places
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
@@ -81,7 +92,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Places in a trip
server.registerResource(
if (canRead(scopes, 'places')) server.registerResource(
'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
@@ -95,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget items
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
@@ -108,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing checklist
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
@@ -121,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Reservations (flights, hotels, restaurants)
server.registerResource(
if (canRead(scopes, 'reservations')) server.registerResource(
'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
@@ -134,7 +145,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Day notes
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
@@ -148,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Accommodations (hotels, rentals) per trip
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
@@ -161,7 +172,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Trip members (owner + collaborators)
server.registerResource(
if (canReadTrips(scopes)) server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
@@ -176,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Collab notes for a trip
server.registerResource(
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
@@ -188,21 +199,8 @@ export function registerResources(server: McpServer, userId: number): void {
}
);
// Trip files (active, not trash)
server.registerResource(
'trip-files',
new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }),
{ description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const files = listFiles(id, false);
return jsonContent(uri.href, files);
}
);
// Trip to-do list
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'todos')) server.registerResource(
'trip-todos',
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
@@ -214,7 +212,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
);
// All place categories (global, no trip filter)
// All place categories (global, no trip filter) — safe for any authenticated session
server.registerResource(
'categories',
'trek://categories',
@@ -226,7 +224,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's bucket list
server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
@@ -237,7 +235,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// User's visited countries
server.registerResource(
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
@@ -248,7 +246,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget per-person summary
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-per-person',
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
@@ -261,7 +259,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Budget settlement
server.registerResource(
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
'trip-budget-settlement',
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
@@ -274,7 +272,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Packing bags
server.registerResource(
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
'trip-packing-bags',
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
@@ -287,7 +285,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// In-app notifications
server.registerResource(
if (canRead(scopes, 'notifications')) server.registerResource(
'notifications-in-app',
'trek://notifications/in-app',
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
@@ -298,7 +296,7 @@ export function registerResources(server: McpServer, userId: number): void {
);
// Atlas stats and regions (addon-gated)
if (isAddonEnabled('atlas')) {
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) {
server.registerResource(
'atlas-stats',
'trek://atlas/stats',
@@ -321,7 +319,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Collab polls & messages (addon-gated)
if (isAddonEnabled('collab')) {
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
server.registerResource(
'trip-collab-polls',
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
@@ -348,7 +346,7 @@ export function registerResources(server: McpServer, userId: number): void {
}
// Vacay resources (addon-gated)
if (isAddonEnabled('vacay')) {
if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) {
server.registerResource(
'vacay-plan',
'trek://vacay/plan',
+107
View File
@@ -0,0 +1,107 @@
// ---------------------------------------------------------------------------
// OAuth 2.1 scope definitions for TREK MCP
// ---------------------------------------------------------------------------
export const SCOPES = {
TRIPS_READ: 'trips:read',
TRIPS_WRITE: 'trips:write',
TRIPS_DELETE: 'trips:delete',
TRIPS_SHARE: 'trips:share',
PLACES_READ: 'places:read',
PLACES_WRITE: 'places:write',
ATLAS_READ: 'atlas:read',
ATLAS_WRITE: 'atlas:write',
PACKING_READ: 'packing:read',
PACKING_WRITE: 'packing:write',
TODOS_READ: 'todos:read',
TODOS_WRITE: 'todos:write',
BUDGET_READ: 'budget:read',
BUDGET_WRITE: 'budget:write',
RESERVATIONS_READ: 'reservations:read',
RESERVATIONS_WRITE: 'reservations:write',
COLLAB_READ: 'collab:read',
COLLAB_WRITE: 'collab:write',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_WRITE: 'notifications:write',
VACAY_READ: 'vacay:read',
VACAY_WRITE: 'vacay:write',
GEO_READ: 'geo:read',
WEATHER_READ: 'weather:read',
} as const;
export type Scope = typeof SCOPES[keyof typeof SCOPES];
export const ALL_SCOPES: Scope[] = Object.values(SCOPES) as Scope[];
export interface ScopeInfo {
label: string;
description: string;
group: string;
}
export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, and members', group: 'Trips' },
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
'trips:share': { label: 'Manage share links', description: 'Create, update, and revoke public share links for trips', group: 'Trips' },
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, and categories', group: 'Places' },
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, and tags', group: 'Places' },
'atlas:read': { label: 'View Atlas', description: 'Read visited countries, regions, and bucket list', group: 'Atlas' },
'atlas:write': { label: 'Manage Atlas', description: 'Mark countries and regions visited, manage bucket list', group: 'Atlas' },
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
'todos:read': { label: 'View to-do lists', description: 'Read trip to-do items and category assignees', group: 'To-dos' },
'todos:write': { label: 'Manage to-do lists', description: 'Create, update, toggle, delete, and reorder to-do items', group: 'To-dos' },
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, and messages', group: 'Collaboration' },
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, polls, and messages', group: 'Collaboration' },
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
};
// ---------------------------------------------------------------------------
// Scope enforcement helpers
// null scopes = static trek_ token = full access
// ---------------------------------------------------------------------------
/** trips:read OR trips:write OR trips:delete OR trips:share all grant read access to trips */
export function canReadTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.some(s => s === 'trips:read' || s === 'trips:write' || s === 'trips:delete' || s === 'trips:share');
}
/** group:write grants write access; for trips canReadTrips handles read */
export function canWrite(scopes: string[] | null, group: string): boolean {
if (!scopes) return true;
return scopes.includes(`${group}:write`);
}
/** group:read OR group:write grant read access */
export function canRead(scopes: string[] | null, group: string): boolean {
if (!scopes) return true;
return scopes.some(s => s === `${group}:read` || s === `${group}:write`);
}
/** trips:delete is a separate scope from trips:write */
export function canDeleteTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.includes('trips:delete');
}
/** trips:share is a separate scope for managing public share links */
export function canShareTrips(scopes: string[] | null): boolean {
if (!scopes) return true;
return scopes.includes('trips:share');
}
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
return { valid: invalid.length === 0, invalid };
}
+41
View File
@@ -0,0 +1,41 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
export interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
scopes: string[] | null;
/** OAuth 2.1 client_id that owns this session; null for static-token / JWT sessions */
clientId: string | null;
/** true when authenticated via static trek_ token — triggers deprecation prompt */
isStaticToken: boolean;
lastActivity: number;
}
export const sessions = new Map<string, McpSession>();
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
/** Terminate MCP sessions for a specific (user, OAuth client) pair.
* Used when an OAuth token or session is revoked so only the affected client's
* sessions are closed, not sessions from other clients for the same user. */
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
if (session.userId === userId && session.clientId === clientId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
+16 -16
View File
@@ -15,34 +15,34 @@ import { registerTripTools } from './tools/trips';
import { registerVacayTools } from './tools/vacay';
import { registerMcpPrompts } from './tools/prompts';
export function registerTools(server: McpServer, userId: number): void {
registerTripTools(server, userId);
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
registerTripTools(server, userId, scopes, getDeprecationNotice);
registerPlaceTools(server, userId);
registerPlaceTools(server, userId, scopes);
registerBudgetTools(server, userId);
registerBudgetTools(server, userId, scopes);
registerPackingTools(server, userId);
registerPackingTools(server, userId, scopes);
registerReservationTools(server, userId);
registerReservationTools(server, userId, scopes);
registerDayTools(server, userId);
registerDayTools(server, userId, scopes);
registerAssignmentTools(server, userId);
registerAssignmentTools(server, userId, scopes);
registerTagTools(server, userId);
registerTagTools(server, userId, scopes);
registerMapsWeatherTools(server, userId);
registerMapsWeatherTools(server, userId, scopes);
registerNotificationTools(server, userId);
registerNotificationTools(server, userId, scopes);
registerAtlasTools(server, userId);
registerAtlasTools(server, userId, scopes);
registerCollabTools(server, userId);
registerCollabTools(server, userId, scopes);
registerVacayTools(server, userId);
registerVacayTools(server, userId, scopes);
registerTodoTools(server, userId);
registerTodoTools(server, userId, scopes);
registerMcpPrompts(server, userId);
registerMcpPrompts(server, userId, isStaticToken);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { broadcast } from '../../websocket';
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
broadcast(tripId, event, payload);
broadcast(tripId, event, { ...payload, _source: 'mcp' });
} catch (err) {
console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err);
}
+16 -8
View File
@@ -15,11 +15,15 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerAssignmentTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerAssignmentTools(server: McpServer, userId: number): void {
// --- ASSIGNMENTS ---
server.registerTool(
if (W) server.registerTool(
'assign_place_to_day',
{
description: 'Assign a place to a specific day in a trip.',
@@ -42,7 +46,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'unassign_place',
{
description: 'Remove a place assignment from a day.',
@@ -64,7 +68,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'update_assignment_time',
{
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
@@ -91,7 +95,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
}
);
server.registerTool(
if (W) server.registerTool(
'move_assignment',
{
description: 'Move a place assignment to a different day.',
@@ -107,13 +111,15 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
return ok({ assignment: result.assignment });
}
);
server.registerTool(
if (R) server.registerTool(
'get_assignment_participants',
{
description: 'Get the list of users participating in a specific place assignment.',
@@ -125,12 +131,13 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
},
async ({ tripId, assignmentId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = getAssignmentParticipants(assignmentId);
return ok({ participants });
}
);
server.registerTool(
if (W) server.registerTool(
'set_assignment_participants',
{
description: 'Set the participants for a place assignment (replaces current list).',
@@ -144,6 +151,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
async ({ tripId, assignmentId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = setAssignmentParticipants(assignmentId, userIds);
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
@@ -152,7 +160,7 @@ export function registerAssignmentTools(server: McpServer, userId: number): void
// --- REORDER ---
server.registerTool(
if (W) server.registerTool(
'reorder_day_assignments',
{
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
+18 -13
View File
@@ -7,16 +7,23 @@ import {
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
} from '../../services/atlasService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerAtlasTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'atlas');
const W = canWrite(scopes, 'atlas');
if (!isAddonEnabled(ADDON_IDS.ATLAS)) return;
export function registerAtlasTools(server: McpServer, userId: number): void {
// --- BUCKET LIST ---
server.registerTool(
if (W) server.registerTool(
'create_bucket_list_item',
{
description: 'Add a destination to your personal travel bucket list.',
@@ -36,7 +43,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_bucket_list_item',
{
description: 'Remove an item from your travel bucket list.',
@@ -55,7 +62,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
// --- ATLAS ---
server.registerTool(
if (W) server.registerTool(
'mark_country_visited',
{
description: 'Mark a country as visited in your Atlas.',
@@ -71,7 +78,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'unmark_country_visited',
{
description: 'Remove a country from your visited countries in Atlas.',
@@ -89,8 +96,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
// --- ATLAS EXPANDED ---
if (isAddonEnabled('atlas')) {
server.registerTool(
if (R) server.registerTool(
'get_atlas_stats',
{
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
@@ -103,7 +109,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_visited_regions',
{
description: 'List all manually visited sub-country regions for the current user.',
@@ -116,7 +122,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'mark_region_visited',
{
description: 'Mark a sub-country region as visited.',
@@ -135,7 +141,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'unmark_region_visited',
{
description: 'Remove a region from the visited list.',
@@ -151,7 +157,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_country_atlas_places',
{
description: 'Get places saved in the user\'s atlas for a specific country.',
@@ -166,7 +172,7 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_bucket_list_item',
{
description: 'Update a bucket list item (notes, name, target date, location).',
@@ -188,5 +194,4 @@ export function registerAtlasTools(server: McpServer, userId: number): void {
return ok({ item });
}
);
}
}
+13 -6
View File
@@ -12,11 +12,17 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerBudgetTools(server: McpServer, userId: number): void {
export function registerBudgetTools(server: McpServer, userId: number, scopes: string[] | null): void {
const W = canWrite(scopes, 'budget');
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
// --- BUDGET ---
server.registerTool(
if (W) server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip.',
@@ -38,7 +44,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_budget_item',
{
description: 'Delete a budget item from a trip.',
@@ -60,7 +66,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
// --- BUDGET (update) ---
server.registerTool(
if (W) server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip.',
@@ -88,7 +94,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
// --- BUDGET ADVANCED ---
server.registerTool(
if (W) server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list).',
@@ -108,7 +114,7 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_budget_member_paid',
{
description: 'Mark or unmark a member as having paid their share of a budget item.',
@@ -128,4 +134,5 @@ export function registerBudgetTools(server: McpServer, userId: number): void {
return ok({ member });
}
);
} // isAddonEnabled(BUDGET)
}
+21 -16
View File
@@ -8,16 +8,23 @@ import {
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerCollabTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'collab');
const W = canWrite(scopes, 'collab');
if (!isAddonEnabled(ADDON_IDS.COLLAB)) return;
export function registerCollabTools(server: McpServer, userId: number): void {
// --- COLLAB NOTES ---
server.registerTool(
if (W) server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
@@ -40,7 +47,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
@@ -65,7 +72,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
@@ -87,9 +94,8 @@ export function registerCollabTools(server: McpServer, userId: number): void {
// --- COLLAB POLLS & CHAT ---
if (isAddonEnabled('collab')) {
server.registerTool(
'list_collab_polls',
if (R) server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
inputSchema: {
@@ -104,7 +110,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
@@ -126,7 +132,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
@@ -146,7 +152,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
@@ -166,7 +172,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
@@ -186,7 +192,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
@@ -203,7 +209,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
@@ -224,7 +230,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
@@ -244,7 +250,7 @@ export function registerCollabTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
@@ -264,5 +270,4 @@ export function registerCollabTools(server: McpServer, userId: number): void {
return ok({ reactions: result.reactions });
}
);
}
}
+6 -1
View File
@@ -16,8 +16,11 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'trips')) return;
export function registerDayTools(server: McpServer, userId: number): void {
// --- DAYS ---
server.registerTool(
@@ -75,6 +78,7 @@ export function registerDayTools(server: McpServer, userId: number): void {
async ({ tripId, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
deleteDay(dayId);
safeBroadcast(tripId, 'day:deleted', { id: dayId });
return ok({ success: true });
@@ -149,6 +153,7 @@ export function registerDayTools(server: McpServer, userId: number): void {
async ({ tripId, accommodationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { linkedReservationId } = deleteAccommodation(accommodationId);
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
return ok({ success: true, linkedReservationId });
+10 -6
View File
@@ -6,11 +6,15 @@ import {
TOOL_ANNOTATIONS_READONLY,
ok,
} from './_shared';
import { canRead } from '../scopes';
export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void {
const canGeo = canRead(scopes, 'geo');
const canWeather = canRead(scopes, 'weather');
export function registerMapsWeatherTools(server: McpServer, userId: number): void {
// --- MAPS EXTRAS ---
server.registerTool(
if (canGeo) server.registerTool(
'get_place_details',
{
description: 'Fetch detailed information about a place by its Google Place ID.',
@@ -27,7 +31,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
}
);
server.registerTool(
if (canGeo) server.registerTool(
'reverse_geocode',
{
description: 'Get a human-readable address for given coordinates.',
@@ -45,7 +49,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
}
);
server.registerTool(
if (canGeo) server.registerTool(
'resolve_maps_url',
{
description: 'Resolve a Google Maps share URL to coordinates and place name.',
@@ -63,7 +67,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
// --- WEATHER ---
server.registerTool(
if (canWeather) server.registerTool(
'get_weather',
{
description: 'Get weather forecast for a location and date.',
@@ -85,7 +89,7 @@ export function registerMapsWeatherTools(server: McpServer, userId: number): voi
}
);
server.registerTool(
if (canWeather) server.registerTool(
'get_detailed_weather',
{
description: 'Get hourly/detailed weather forecast for a location and date.',
+10 -6
View File
@@ -11,11 +11,15 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerNotificationTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'notifications');
const W = canWrite(scopes, 'notifications');
export function registerNotificationTools(server: McpServer, userId: number): void {
// --- NOTIFICATIONS ---
server.registerTool(
if (R) server.registerTool(
'list_notifications',
{
description: 'List in-app notifications for the current user.',
@@ -32,7 +36,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (R) server.registerTool(
'get_unread_notification_count',
{
description: 'Get the number of unread in-app notifications.',
@@ -45,7 +49,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_notification_read',
{
description: 'Mark a single notification as read.',
@@ -62,7 +66,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_notification_unread',
{
description: 'Mark a single notification as unread.',
@@ -79,7 +83,7 @@ export function registerNotificationTools(server: McpServer, userId: number): vo
}
);
server.registerTool(
if (W) server.registerTool(
'mark_all_notifications_read',
{
description: "Mark all of the current user's notifications as read.",
+24 -16
View File
@@ -16,11 +16,19 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'packing');
const W = canWrite(scopes, 'packing');
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING ---
server.registerTool(
if (W) server.registerTool(
'create_packing_item',
{
description: 'Add an item to the packing checklist for a trip.',
@@ -40,7 +48,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_packing_item',
{
description: 'Check or uncheck a packing item.',
@@ -61,7 +69,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_packing_item',
{
description: 'Remove an item from the packing checklist.',
@@ -83,7 +91,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING (update) ---
server.registerTool(
if (W) server.registerTool(
'update_packing_item',
{
description: 'Rename a packing item or change its category.',
@@ -108,7 +116,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING ADVANCED ---
server.registerTool(
if (W) server.registerTool(
'reorder_packing_items',
{
description: 'Set the display order of packing items within a trip.',
@@ -127,7 +135,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_packing_bags',
{
description: 'List all packing bags for a trip.',
@@ -143,7 +151,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_packing_bag',
{
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
@@ -163,7 +171,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_packing_bag',
{
description: 'Rename or recolor a packing bag.',
@@ -188,7 +196,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_packing_bag',
{
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
@@ -207,7 +215,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_bag_members',
{
description: 'Assign trip members to a packing bag (determines who packs what bag).',
@@ -227,7 +235,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_packing_category_assignees',
{
description: 'Get which trip members are assigned to each packing category.',
@@ -243,7 +251,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_packing_category_assignees',
{
description: 'Assign trip members to a packing category.',
@@ -263,7 +271,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'apply_packing_template',
{
description: 'Apply a packing template to a trip (adds items from the template).',
@@ -283,7 +291,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template.',
@@ -301,7 +309,7 @@ export function registerPackingTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list.',
+11 -7
View File
@@ -10,11 +10,15 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerPlaceTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerPlaceTools(server: McpServer, userId: number): void {
// --- PLACES ---
server.registerTool(
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.',
@@ -43,7 +47,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_place',
{
description: 'Update an existing place in a trip.',
@@ -80,7 +84,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_place',
{
description: 'Delete a place from a trip.',
@@ -100,7 +104,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
@@ -122,7 +126,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
// --- CATEGORIES ---
server.registerTool(
if (R) server.registerTool(
'list_categories',
{
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
@@ -137,7 +141,7 @@ export function registerPlaceTools(server: McpServer, userId: number): void {
// --- SEARCH ---
server.registerTool(
if (R) server.registerTool(
'search_place',
{
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
+25 -3
View File
@@ -3,8 +3,30 @@ import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { listItems as listPackingItems } from '../../services/packingService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerMcpPrompts(server: McpServer, _userId: number): void {
export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void {
if (isStaticToken) {
server.registerPrompt(
'token_auth_notice',
{
title: 'Static Token Auth Notice',
description: 'Notification that this session uses a static API token which will be deprecated',
argsSchema: {},
},
async () => ({
description: 'Static token deprecation notice',
messages: [{
role: 'user' as const,
content: {
type: 'text' as const,
text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.',
},
}],
})
);
}
const userId = _userId;
server.registerPrompt(
@@ -43,7 +65,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
}
);
server.registerPrompt(
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
'packing-list',
{
title: 'Packing List',
@@ -77,7 +99,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
}
);
server.registerPrompt(
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
+4 -1
View File
@@ -13,8 +13,11 @@ import {
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
export function registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'reservations')) return;
export function registerReservationTools(server: McpServer, userId: number): void {
server.registerTool(
'create_reservation',
+12 -6
View File
@@ -1,17 +1,21 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { listTags, createTag, updateTag, deleteTag } from '../../services/tagService';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../services/tagService';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerTagTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
export function registerTagTools(server: McpServer, userId: number): void {
// --- TAGS ---
server.registerTool(
if (R) server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
@@ -24,7 +28,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
@@ -41,7 +45,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
@@ -54,13 +58,14 @@ export function registerTagTools(server: McpServer, userId: number): void {
},
async ({ tagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
const tag = updateTag(tagId, name, color);
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
return ok({ tag });
}
);
server.registerTool(
if (W) server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
@@ -71,6 +76,7 @@ export function registerTagTools(server: McpServer, userId: number): void {
},
async ({ tagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
deleteTag(tagId);
return ok({ success: true });
}
+17 -9
View File
@@ -12,11 +12,19 @@ import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'todos');
const W = canWrite(scopes, 'todos');
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
export function registerTodoTools(server: McpServer, userId: number): void {
// --- TODOS ---
server.registerTool(
if (R) server.registerTool(
'list_todos',
{
description: 'List all to-do items for a trip, ordered by position.',
@@ -32,7 +40,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'create_todo',
{
description: 'Create a new to-do item for a trip.',
@@ -56,7 +64,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_todo',
{
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
@@ -88,7 +96,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_todo',
{
description: 'Mark a to-do item as checked (done) or unchecked.',
@@ -109,7 +117,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_todo',
{
description: 'Delete a to-do item.',
@@ -129,7 +137,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'reorder_todos',
{
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
@@ -147,7 +155,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_todo_category_assignees',
{
description: 'Get the default assignees configured per to-do category for a trip.',
@@ -163,7 +171,7 @@ export function registerTodoTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_todo_category_assignees',
{
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
+73 -29
View File
@@ -13,22 +13,28 @@ import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../services/shareService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
import { listFiles } from '../../services/fileService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
const R = canReadTrips(scopes);
const W = canWrite(scopes, 'trips');
const D = canDeleteTrips(scopes);
const S = canShareTrips(scopes);
export function registerTripTools(server: McpServer, userId: number): void {
// --- TRIPS ---
server.registerTool(
if (W) server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
@@ -61,7 +67,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_trip',
{
description: 'Update an existing trip\'s details.',
@@ -94,7 +100,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (D) server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
@@ -111,6 +117,8 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
server.registerTool(
'list_trips',
{
@@ -121,7 +129,15 @@ export function registerTripTools(server: McpServer, userId: number): void {
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ include_archived }) => {
const notice = getDeprecationNotice();
const trips = listTrips(userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
);
@@ -131,7 +147,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
server.registerTool(
'get_trip_summary',
{
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, full budget line items with totals, full packing list with checked status, reservations, collab notes, to-do items, files, and collab poll/message counts. Use this as a context loader before planning or modifying a trip.',
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
@@ -141,31 +157,59 @@ export function registerTripTools(server: McpServer, userId: number): void {
if (!canAccessTrip(tripId, userId)) return noAccess();
const summary = getTripSummary(tripId);
if (!summary) return noAccess();
const todos = listTodoItems(tripId);
const files = listFiles(tripId, false).map((f: any) => ({
id: f.id,
original_name: f.original_name,
mime_type: f.mime_type,
file_size: f.file_size,
starred: !!f.starred,
deleted: !!f.deleted_at,
created_at: f.created_at,
}));
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
const canReadRes = canRead(scopes, 'reservations');
const todos = canReadTodos ? listTodoItems(tripId) : [];
let pollCount = 0;
if (isAddonEnabled('collab')) {
pollCount = listPolls(tripId).length;
}
let messageCount = 0;
if (isAddonEnabled('collab')) {
if (canReadCollab) {
pollCount = listPolls(tripId).length;
messageCount = countMessages(tripId);
}
return ok({ ...summary, todos, files, pollCount, messageCount });
const notice = getDeprecationNotice();
const data = {
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
],
};
return ok({
...summary,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
});
}
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
server.registerTool(
if (R) server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
@@ -183,7 +227,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
@@ -210,7 +254,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
@@ -232,7 +276,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'copy_trip',
{
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
@@ -255,7 +299,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'export_trip_ics',
{
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
@@ -275,7 +319,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (S) server.registerTool(
'get_share_link',
{
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
@@ -291,7 +335,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (S) server.registerTool(
'create_share_link',
{
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
@@ -319,7 +363,7 @@ export function registerTripTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (S) server.registerTool(
'delete_share_link',
{
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
+29 -24
View File
@@ -13,15 +13,20 @@ import {
getCountries as getHolidayCountries, getHolidays,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerVacayTools(server: McpServer, userId: number): void {
if (isAddonEnabled('vacay')) {
server.registerTool(
export function registerVacayTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'vacay');
const W = canWrite(scopes, 'vacay');
if (isAddonEnabled(ADDON_IDS.VACAY)) {
if (R) server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
@@ -34,7 +39,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
@@ -55,7 +60,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
@@ -72,7 +77,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
@@ -86,7 +91,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
@@ -106,7 +111,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'accept_vacay_invite',
{
description: 'Accept a pending invitation to join another user\'s vacation plan.',
@@ -123,7 +128,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
@@ -138,7 +143,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
@@ -155,7 +160,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'dissolve_vacay_plan',
{
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
@@ -169,7 +174,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
@@ -183,7 +188,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
@@ -200,7 +205,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
@@ -217,7 +222,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
@@ -233,7 +238,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
@@ -250,7 +255,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
@@ -268,7 +273,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
@@ -284,7 +289,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
@@ -302,7 +307,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
@@ -322,7 +327,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
@@ -342,7 +347,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (W) server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
@@ -359,7 +364,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
@@ -373,7 +378,7 @@ export function registerVacayTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
if (R) server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
+36 -13
View File
@@ -12,6 +12,18 @@ export function extractToken(req: Request): string | null {
return (authHeader && authHeader.split(' ')[1]) || null;
}
function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user ?? null;
} catch {
return null;
}
}
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const token = extractToken(req);
@@ -20,20 +32,31 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
return;
}
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
} catch (err: unknown) {
const user = verifyJwtAndLoadUser(token);
if (!user) {
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
};
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
* Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
* to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
const cookieToken = (req as any).cookies?.trek_session;
if (!cookieToken) {
res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
return;
}
const user = verifyJwtAndLoadUser(cookieToken);
if (!user) {
res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
};
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
@@ -74,4 +97,4 @@ const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void
next();
};
export { authenticate, optionalAuth, adminOnly, demoUploadBlock };
export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };
+23 -5
View File
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
import { invalidateMcpSessions } from '../mcp';
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
const router = express.Router();
@@ -292,6 +293,8 @@ router.put('/addons/:id', (req: Request, res: Response) => {
ip: getClientIp(req),
details: result.auditDetails,
});
// Invalidate all MCP sessions so they re-create with the updated addon tool set
invalidateMcpSessions();
res.json({ addon: result.addon });
});
@@ -307,6 +310,25 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
res.json({ success: true });
});
// ── OAuth Sessions ─────────────────────────────────────────────────────────
router.get('/oauth-sessions', (_req: Request, res: Response) => {
res.json({ sessions: svc.listOAuthSessions() });
});
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
const result = svc.revokeOAuthSession(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oauth_session.revoke',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── JWT Rotation ───────────────────────────────────────────────────────────
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
@@ -314,12 +336,8 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
if (result.error) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
user_id: authReq.user?.id ?? null,
username: authReq.user?.username ?? 'unknown',
userId: authReq.user.id,
action: 'admin.rotate_jwt_secret',
target_type: 'system',
target_id: null,
details: null,
ip: getClientIp(req),
});
res.json({ success: true });
+6 -2
View File
@@ -36,12 +36,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
}
else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
});
@@ -51,10 +52,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
});
router.post('/test', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
@@ -64,7 +68,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
}
else{
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
});
+420
View File
@@ -0,0 +1,420 @@
import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import {
validateAuthorizeRequest,
createAuthCode,
consumeAuthCode,
saveConsent,
issueTokens,
refreshTokens,
revokeToken,
verifyPKCE,
authenticateClient,
isValidRedirectUri,
listOAuthClients,
createOAuthClient,
deleteOAuthClient,
rotateOAuthClientSecret,
listOAuthSessions,
revokeSession,
AuthorizeParams,
} from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
// ---------------------------------------------------------------------------
interface RateEntry { count: number; first: number; }
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
const store = new Map<string, RateEntry>();
setInterval(() => {
const now = Date.now();
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
}, windowMs).unref();
return (req: Request, res: Response, next: () => void): void => {
const key = keyFn(req);
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /.well-known, /oauth/token, /oauth/revoke
// ---------------------------------------------------------------------------
export const oauthPublicRouter = express.Router();
// RFC 8414 discovery document
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
// M2: return 404 (not 403) so feature presence isn't fingerprinted
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
});
});
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
// M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
}
if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
}
// ---- authorization_code grant ----
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
}
const pending = consumeAuthCode(code);
// H5: collapse all invalid_grant cases to one message; log specifics server-side
if (!pending) {
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.clientId !== client_id) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.redirectUri !== redirect_uri) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
return res.json(tokens);
}
// ---- refresh_token grant ----
if (grant_type === 'refresh_token') {
if (!refresh_token) {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') {
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
}
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
});
}
return res.json(result.tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// RFC 7591 Dynamic Client Registration endpoint
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
const ip = getClientIp(req);
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
if (redirectUris.length === 0) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
}
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
const clientName = rawName || 'MCP Client';
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
const isPublic = authMethod === 'none';
// Resolve requested scopes — scope is required; no implicit full-access grant
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
}
const rawScope = body.scope;
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
if (requestedScopes.length === 0) {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
}
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
isPublic,
createdVia: 'dcr',
});
if (result.error) {
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
}
const client = result.client!;
const now = Math.floor(Date.now() / 1000);
return res.status(201).json({
client_id: client.client_id,
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
client_id_issued_at: now,
redirect_uris: client.redirect_uris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: (client.allowed_scopes as string[]).join(' '),
client_name: client.name,
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
});
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
if (!token || !client_id) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
}
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
revokeToken(token, client_id, undefined, ip);
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
return res.status(200).json({});
});
// ---------------------------------------------------------------------------
// API router: /api/oauth/* — authenticated endpoints used by the SPA
// ---------------------------------------------------------------------------
export const oauthApiRouter = express.Router();
// SPA calls this on page load to validate OAuth params before rendering consent UI
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const params = req.query as Partial<AuthorizeParams>;
const userId = (req as OptionalAuthRequest).user?.id ?? null;
const result = validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
},
userId,
);
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
// (validateAuthorizeRequest already does this, but be explicit here)
if (userId === null && result.valid) {
return res.json({ valid: result.valid, loginRequired: true });
}
// For unauthenticated error cases return a generic error to prevent oracle enumeration
if (userId === null && !result.valid) {
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
}
return res.json(result);
});
// User submits consent (approve or deny) — requires cookie-only auth (M7)
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved,
} = req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
};
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'MCP is not enabled' });
}
if (!approved) {
// User denied — redirect with error
const url = new URL(redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied the authorization request');
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
}
// Re-validate all params (server-side re-check after user action)
const params: AuthorizeParams = {
response_type: 'code',
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
const validation = validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
}
const scopes = validation.scopes!;
// Store consent (union with any existing scopes)
saveConsent(client_id, user.id, scopes, ip);
// Issue auth code
const code = createAuthCode({
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scopes,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
if (!code) {
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
}
const url = new URL(redirect_uri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
});
// ---- OAuth client CRUD ----
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ clients: listOAuthClients(user.id) });
});
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 {
name: string;
redirect_uris: string[];
allowed_scopes: string[];
};
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
oauthApiRouter.post('/clients/:id/rotate', 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 result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ client_secret: result.client_secret });
});
oauthApiRouter.delete('/clients/:id', 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 result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
// ---- Active OAuth sessions ----
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ sessions: listOAuthSessions(user.id) });
});
oauthApiRouter.delete('/sessions/:id', 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 result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
+25 -1
View File
@@ -7,7 +7,7 @@ import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions } from '../mcp';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
@@ -603,6 +603,30 @@ export function deleteMcpToken(id: string) {
return {};
}
// ── OAuth Sessions ─────────────────────────────────────────────────────────
export function listOAuthSessions() {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
JOIN users u ON u.id = ot.user_id
WHERE ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all() as (Record<string, unknown> & { scopes: string })[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
}
export function revokeOAuthSession(id: string) {
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
revokeUserSessionsForClient(row.user_id, row.client_id);
return {};
}
// ── JWT Rotation ───────────────────────────────────────────────────────────
export function rotateJwtSecret(): { error?: string; status?: number } {
+130 -30
View File
@@ -19,26 +19,62 @@ import {
SyncAlbumResult,
AssetInfo
} from './helpersService';
import { send as sendNotification } from '../notificationService';
const SYNOLOGY_PROVIDER = 'synologyphotos';
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
// The API endpoint is always at {base_url}/webapi/entry.cgi.
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Invalid credentials.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication code required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum login attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};
interface SynologyUserRecord {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
};
interface SynologyCredentials {
synology_url: string;
synology_username: string;
synology_password: string;
synology_skip_ssl: boolean;
}
interface SynologySettings {
synology_url: string;
synology_username: string;
synology_skip_ssl: boolean;
connected: boolean;
}
@@ -84,7 +120,7 @@ interface SynologyPhotoItem {
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try {
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) {
return fail('User not found', 404);
@@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
}
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
@@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
synology_url: user.data.synology_url,
synology_username: user.data.synology_username,
synology_password: password,
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
});
}
@@ -129,7 +166,7 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
return body;
}
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
try {
const resp = await safeFetch(endpoint, {
@@ -139,12 +176,20 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
},
body,
signal: AbortSignal.timeout(30000) as any,
});
}, { rejectUnauthorized: !skipSsl });
if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status);
}
const response = await resp.json() as SynologyApiResponse<T>;
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
if (!response.success) {
const code = response.error.code;
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
return fail(message, httpStatus);
}
return success(response.data);
} catch (error) {
if (error instanceof SsrfBlockedError) {
return fail(error.message, 400);
@@ -153,25 +198,41 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
}
}
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
const SYNOLOGY_DEVICE_NAME = 'trek';
async function _loginToSynology(
url: string,
username: string,
password: string,
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
): Promise<ServiceResult<{ sid: string; did?: string }>> {
const { otp, deviceId, skipSsl = false } = opts;
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '3',
version: '6',
account: username,
passwd: password,
format: 'sid',
client: 'browser',
device_name: SYNOLOGY_DEVICE_NAME,
});
if (otp && otp.trim()) {
body.append('otp_code', otp.trim());
body.append('enable_device_token', 'yes');
}
if (deviceId) {
body.append('device_id', deviceId);
}
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
if (!result.success) {
return result as ServiceResult<string>;
return result as ServiceResult<{ sid: string; did?: string }>;
}
if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500);
}
return success(result.data.sid);
return success({ sid: result.data.sid, did: result.data.did });
}
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
@@ -185,8 +246,9 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
return session as ServiceResult<T>;
}
const skipSsl = creds.data.synology_skip_ssl;
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
_clearSynologySID(userId);
@@ -194,7 +256,7 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
}
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
}
return result;
}
@@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
function _clearSynologySession(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
// The first segment must be a non-empty integer (the unit ID used for API calls).
@@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string;
}
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
if (cachedSid.success && cachedSid.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
_clearSynologySID(userId);
@@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
return creds as ServiceResult<string>;
}
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
// Use stored device ID to skip OTP on re-login (trusted device flow)
const storedDid = cached.success && cached.data?.synology_did
? (decrypt_api_key(cached.data.synology_did) || undefined)
: undefined;
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
deviceId: storedDid,
skipSsl: creds.data.synology_skip_ssl,
});
if (!resp.success) {
return resp as ServiceResult<string>;
}
const encrypted = encrypt_api_key(resp.data);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId);
return success(resp.data);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
return success(resp.data.sid);
}
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
@@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
return success({
synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '',
synology_skip_ssl: creds.data.synology_skip_ssl,
connected: session.success,
});
}
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
@@ -291,24 +365,42 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
return fail('No stored password found. Please provide a password to save settings.', 400);
}
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
_clearSynologySession(userId);
sendNotification({
event: 'synology_session_cleared',
actorId: null,
params: {},
scope: 'user',
targetId: userId,
});
}
try {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
}
_clearSynologySID(userId);
return success("settings updated");
return success('settings updated');
}
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await _getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
@@ -318,17 +410,25 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
}
}
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
if ('error' in resp) {
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
return success({ connected: false, error: resp.error.message });
}
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
if (resp.data.did) {
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
}
return success({ connected: true, user: { name: synologyUsername } });
}
@@ -13,7 +13,8 @@ export type NotifEventType =
| 'photos_shared'
| 'collab_message'
| 'packing_tagged'
| 'version_available';
| 'version_available'
| 'synology_session_cleared';
export interface AvailableChannels {
email: boolean;
@@ -32,6 +33,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
collab_message: ['inapp', 'email', 'webhook'],
packing_tagged: ['inapp', 'email', 'webhook'],
version_available: ['inapp', 'email', 'webhook'],
synology_session_cleared: ['inapp'],
};
/** Events that target admins only (shown in admin panel, not in user settings). */
@@ -112,6 +112,12 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_admin',
navigateTarget: () => '/admin',
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
textKey: 'notifications.synologySessionCleared.text',
navigateTarget: () => null,
},
};
// ── Fallback config for unknown event types ────────────────────────────────
+15
View File
@@ -104,6 +104,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }),
},
de: {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
@@ -114,6 +115,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }),
},
fr: {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
@@ -124,6 +126,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }),
},
es: {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
@@ -134,6 +137,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }),
},
nl: {
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
@@ -144,6 +148,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }),
},
ru: {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
@@ -154,6 +159,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }),
},
zh: {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
@@ -164,6 +170,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }),
},
'zh-TW': {
trip_invite: p => ({ title: `邀請加入「${p.trip}`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
@@ -173,6 +180,8 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
collab_message: p => ({ title: `${p.trip}」中的新訊息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }),
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }),
synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }),
},
ar: {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
@@ -183,6 +192,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }),
},
br: {
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
@@ -193,6 +203,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }),
},
cs: {
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
@@ -203,6 +214,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }),
},
hu: {
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
@@ -213,6 +225,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }),
},
it: {
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
@@ -223,6 +236,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }),
},
pl: {
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
@@ -233,6 +247,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }),
},
};
+638
View File
@@ -0,0 +1,638 @@
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
import { db } from '../db/database';
import { isAddonEnabled } from './adminService';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// PKCE format (RFC 7636)
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
// ---------------------------------------------------------------------------
// In-memory auth code store (short-lived, no need for DB persistence)
// ---------------------------------------------------------------------------
interface PendingCode {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
}
const MAX_PENDING_CODES = 500;
const pendingCodes = new Map<string, PendingCode>();
setInterval(() => {
const now = Date.now();
for (const [key, entry] of pendingCodes) {
if (now > entry.expiresAt) pendingCodes.delete(key);
}
}, 60_000).unref();
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
interface OAuthClientRow {
id: string;
user_id: number;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
}
interface OAuthTokenRow {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch { return false; }
}
function generateAccessToken(): string {
return 'trekoa_' + randomBytes(32).toString('hex');
}
function generateRefreshToken(): string {
return 'trekrf_' + randomBytes(32).toString('hex');
}
// ---------------------------------------------------------------------------
// Client management (self-service, gated by MCP addon)
// ---------------------------------------------------------------------------
export function listOAuthClients(userId: number): Record<string, unknown>[] {
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'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
}
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
export function isValidRedirectUri(uri: string): boolean {
try {
const url = new URL(uri);
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
} catch {
return false;
}
}
export function createOAuthClient(
userId: number | null,
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string },
): { error?: string; status?: number; client?: Record<string, unknown> } {
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 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
}
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
}
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
if (userId !== null) {
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
} else {
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count;
if (count >= 500) return { error: 'server_error', status: 503 };
}
const isPublic = options?.isPublic ?? false;
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
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);
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 = ?'
).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
return {
client: {
id: row.id,
user_id: row.user_id,
name: row.name,
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
},
};
}
export function rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
export function deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Auth code (in-memory, 2-minute TTL)
// ---------------------------------------------------------------------------
export function createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
if (pendingCodes.size >= MAX_PENDING_CODES) return null;
const rawCode = randomBytes(32).toString('hex');
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return rawCode;
}
export function consumeAuthCode(code: string): PendingCode | null {
const entry = pendingCodes.get(code);
if (!entry) return null;
pendingCodes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
}
// ---------------------------------------------------------------------------
// Consent management
// ---------------------------------------------------------------------------
export function getConsent(clientId: string, userId: number): string[] | null {
const row = db.prepare(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
).get(clientId, userId) as { scopes: string } | undefined;
return row ? JSON.parse(row.scopes) : null;
}
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
db.prepare(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
).run(clientId, userId, JSON.stringify(merged));
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
return requestedScopes.every(s => existingScopes.includes(s));
}
// ---------------------------------------------------------------------------
// Token issuance
// ---------------------------------------------------------------------------
export function issueTokens(
clientId: string,
userId: number,
scopes: string[],
parentTokenId: number | null = null,
): {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const rawRefresh = generateRefreshToken();
const accessHash = hashToken(rawAccess);
const refreshHash = hashToken(rawRefresh);
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
refresh_token: rawRefresh,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
if (!row) return null;
if (row.revoked_at) return null;
if (new Date(row.access_token_expires_at) < new Date()) return null;
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
};
}
// ---------------------------------------------------------------------------
// Token refresh (rotation + replay detection)
// ---------------------------------------------------------------------------
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
function findChainRoot(tokenId: number): number {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
return current;
}
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
function revokeChain(rootId: number): number[] {
const rows = db.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`).all(rootId) as { id: number }[];
const ids = rows.map(r => r.id);
if (ids.length > 0) {
db.prepare(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
).run(...ids);
}
return ids;
}
export function refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string | undefined,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
return { error: 'invalid_client', status: 401 };
}
}
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
// ---- Replay detection (C3) ----
if (row.revoked_at) {
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
const rootId = findChainRoot(row.id);
revokeChain(rootId);
revokeUserSessionsForClient(row.user_id, clientId);
writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
ip,
});
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
return { error: 'invalid_grant', status: 400 };
}
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation) and issue new pair linked to old row
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
// Terminate active MCP sessions for the old token's client so client must re-authenticate
revokeUserSessionsForClient(row.user_id, clientId);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
// ---------------------------------------------------------------------------
// Token revocation
// ---------------------------------------------------------------------------
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = db.prepare(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
).get(hash, hash, clientId) as { user_id: number } | undefined;
db.prepare(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`).run(hash, hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
// ---------------------------------------------------------------------------
// Active session listing (for user settings page)
// ---------------------------------------------------------------------------
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all(userId) as Record<string, unknown>[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
export function revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// ---------------------------------------------------------------------------
export interface AuthorizeParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
}
export interface ValidateAuthorizeResult {
valid: boolean;
error?: string;
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
loginRequired?: boolean;
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
scopeSelectable?: boolean;
}
export function validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
if (params.response_type !== 'code') {
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
}
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
// H1: Enforce code_challenge format (RFC 7636 §4.2)
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
const allowedUris: string[] = JSON.parse(client.redirect_uris);
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
// Narrow to the intersection: drop scopes the client isn't permitted for rather
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (grantedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
}
if (userId === null) {
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
// allowed_scopes to unauthenticated callers to prevent client enumeration.
return { valid: true, loginRequired: true };
}
const existingConsent = getConsent(params.client_id, userId);
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes);
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
// H1: validate code_verifier format before hashing
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
// Constant-time compare (both are base64url strings of equal length for S256)
if (expected.length !== codeChallenge.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Client authentication (for token endpoint)
// ---------------------------------------------------------------------------
export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
return client;
}
// H4: constant-time comparison to prevent timing side-channel
if (!clientSecret) return null;
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client;
}
+12 -3
View File
@@ -114,17 +114,25 @@ export class SsrfBlockedError extends Error {
}
}
export interface SafeFetchOptions {
rejectUnauthorized?: boolean;
}
/**
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
* between the check and the actual connection (DNS rebinding prevention).
*
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
* applies only the TLS certificate check is relaxed.
*/
export async function safeFetch(url: string, init?: RequestInit): Promise<Response> {
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
}
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!);
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
return fetch(url, { ...init, dispatcher } as any);
}
@@ -133,9 +141,10 @@ export async function safeFetch(url: string, init?: RequestInit): Promise<Respon
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
* goes to the IP we checked, not a re-resolved one.
*/
export function createPinnedDispatcher(resolvedIp: string): Agent {
export function createPinnedDispatcher(resolvedIp: string, rejectUnauthorized = true): Agent {
return new Agent({
connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
const family = resolvedIp.includes(':') ? 6 : 4;
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects