import { DEFAULT_TIMELINE_SOURCES } from "../types/timeline"; import { isValidDateString } from "../utils/timeline"; /** * Allowlisted in-app navigation targets (OPL-2527). * * This is the single source of truth for "where may an agent send the user". * There is deliberately NO arbitrary-URL path: a destination must be a known * key here, so a misbehaving or compromised model cannot navigate the app to * an attacker-controlled location. Hosted MCP returns the resolved canonical * route as a URL; the desktop app additionally drives its webview to it. * * Keys mirror the desktop `(app)` route group. Add a key here when a new * top-level destination should be agent-reachable — not by accepting raw paths. */ const STATIC_ROUTES = { timeline: "/timeline", projects: "/projects", sync: "/sync", settings: "/settings", search: "/search", ingest: "/ingest", invoices: "/invoices", chat: "/chat", "scan-roots": "/scan-roots", security: "/security", } as const; /** A project detail page is dynamic (`/projects/:id`); resolved separately. */ export type AppDestination = keyof typeof STATIC_ROUTES | "project"; /** Allowlisted destinations, for tool descriptions and validation messages. */ export const APP_DESTINATIONS: readonly AppDestination[] = [ ...(Object.keys(STATIC_ROUTES) as (keyof typeof STATIC_ROUTES)[]), "project", ]; /** Allowlisted `/settings` subsections. */ const SETTINGS_SECTIONS = ["templates"] as const; /** Allowlisted timeline view modes (mirrors the timeline URL state). */ const TIMELINE_VIEWS = ["activity", "events", "heatmap"] as const; /** Project ids are cuid2 (lowercase alnum); restrict hard to avoid path injection. */ const PROJECT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; export interface ResolveAppRouteInput { /** One of `APP_DESTINATIONS`. */ destination: string; /** Required when `destination` is `project`: the project id to open. */ projectId?: string; /** Optional subsection, only honored for `settings` (e.g. `templates`). */ section?: string; /** Timeline-only filters; ignored for any other destination. */ view?: string; sources?: string[]; start?: string; end?: string; } export interface ResolvedAppRoute { /** The normalized destination key that was resolved. */ destination: AppDestination; /** Canonical, allowlisted relative route (path + optional query string). */ route: string; } /** Thrown when a navigation request fails validation. */ export class AppRouteError extends Error { constructor(message: string) { super(message); this.name = "AppRouteError"; } } /** * Resolve an allowlisted in-app destination to its canonical relative route. * * Pure and framework-neutral: builds the path + query from the allowlist only. * Throws `AppRouteError` for an unknown destination, a missing/invalid project * id, or an invalid timeline filter — never silently coerces to a default. */ export function resolveAppRoute(input: ResolveAppRouteInput): ResolvedAppRoute { const { destination } = input; if (destination === "project") { const id = input.projectId?.trim(); if (!id) { throw new AppRouteError('destination "project" requires a projectId.'); } if (!PROJECT_ID_PATTERN.test(id)) { throw new AppRouteError(`Invalid projectId: ${input.projectId}`); } return { destination: "project", route: `/projects/${id}` }; } if (!(destination in STATIC_ROUTES)) { throw new AppRouteError( `Unknown destination "${destination}". Allowed: ${APP_DESTINATIONS.join(", ")}.`, ); } const key = destination as keyof typeof STATIC_ROUTES; let path: string = STATIC_ROUTES[key]; if (key === "settings" && input.section !== undefined) { const section = input.section.trim(); if (!(SETTINGS_SECTIONS as readonly string[]).includes(section)) { throw new AppRouteError( `Unknown settings section "${input.section}". Allowed: ${SETTINGS_SECTIONS.join(", ")}.`, ); } path = `${path}/${section}`; } const query = key === "timeline" ? buildTimelineQuery(input) : ""; return { destination: key, route: `${path}${query}` }; } function buildTimelineQuery(input: ResolveAppRouteInput): string { const params = new URLSearchParams(); if (input.view !== undefined) { if (!(TIMELINE_VIEWS as readonly string[]).includes(input.view)) { throw new AppRouteError( `Unknown timeline view "${input.view}". Allowed: ${TIMELINE_VIEWS.join(", ")}.`, ); } params.set("view", input.view); } if (input.sources !== undefined && input.sources.length > 0) { const allowed = new Set(DEFAULT_TIMELINE_SOURCES); const invalid = input.sources.filter((s) => !allowed.has(s)); if (invalid.length > 0) { throw new AppRouteError( `Unknown timeline source(s): ${invalid.join(", ")}.`, ); } params.set("sources", input.sources.join(",")); } for (const key of ["start", "end"] as const) { const value = input[key]; if (value !== undefined) { if (!isValidDateString(value)) { throw new AppRouteError( `Invalid ${key} date "${value}" (expected YYYY-MM-DD).`, ); } params.set(key, value); } } const qs = params.toString(); return qs ? `?${qs}` : ""; }