22 | ),
23 | ssr: false, // Disable SSR for this heavy component
24 | }
25 | );
26 |
27 | export { AgentStatusDashboard };
28 | export type { AgentStatusDashboardProps } from "./agent-status-dashboard";
29 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/test/factories/reset.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Centralized factory reset utilities.
3 | * Call resetAllFactories() in beforeEach() to ensure deterministic test data.
4 | */
5 |
6 | import { resetAuthUserFactory } from "./auth-user-factory";
7 | import { resetCalendarFactory } from "./calendar-factory";
8 | import { resetFilterFactory } from "./filter-factory";
9 | import { resetSearchFactory } from "./search-factory";
10 | import { resetTripFactory } from "./trip-factory";
11 | import { resetUserFactory } from "./user-factory";
12 |
13 | /**
14 | * Resets all factory ID counters to their initial state.
15 | * Call this in beforeEach() to ensure consistent, deterministic IDs across test runs.
16 | *
17 | * @example
18 | * beforeEach(() => {
19 | * resetAllFactories();
20 | * });
21 | */
22 | export const resetAllFactories = (): void => {
23 | resetUserFactory();
24 | resetAuthUserFactory();
25 | resetTripFactory();
26 | resetSearchFactory();
27 | resetFilterFactory();
28 | resetCalendarFactory();
29 | };
30 |
--------------------------------------------------------------------------------
/supabase/migrations/20251220000000_delete_user_memories_rpc.sql:
--------------------------------------------------------------------------------
1 | -- Delete user memories atomically (service_role only).
2 | -- Uses a single transaction via PL/pgSQL function execution.
3 |
4 | CREATE OR REPLACE FUNCTION public.delete_user_memories(
5 | p_user_id UUID
6 | ) RETURNS TABLE (
7 | deleted_turns BIGINT,
8 | deleted_sessions BIGINT
9 | )
10 | LANGUAGE plpgsql
11 | SECURITY DEFINER
12 | SET search_path = public
13 | AS $$
14 | DECLARE
15 | v_deleted_turns BIGINT;
16 | v_deleted_sessions BIGINT;
17 | BEGIN
18 | IF coalesce((current_setting('request.jwt.claims', true)::json->>'role'),'') <> 'service_role' THEN
19 | RAISE EXCEPTION 'Must be called as service role';
20 | END IF;
21 |
22 | DELETE FROM memories.turns WHERE user_id = p_user_id;
23 | GET DIAGNOSTICS v_deleted_turns = ROW_COUNT;
24 |
25 | DELETE FROM memories.sessions WHERE user_id = p_user_id;
26 | GET DIAGNOSTICS v_deleted_sessions = ROW_COUNT;
27 |
28 | RETURN QUERY SELECT v_deleted_turns, v_deleted_sessions;
29 | END;
30 | $$;
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/security/__tests__/internal-key.test.ts:
--------------------------------------------------------------------------------
1 | /** @vitest-environment node */
2 |
3 | import { describe, expect, it } from "vitest";
4 | import { isValidInternalKey } from "@/lib/security/internal-key";
5 |
6 | describe("isValidInternalKey", () => {
7 | it("returns false when provided key is missing", () => {
8 | expect(isValidInternalKey(null, "expected")).toBe(false);
9 | });
10 |
11 | it("throws when expected key is empty", () => {
12 | expect(() => isValidInternalKey("provided", "")).toThrow(
13 | "Missing expected internal key: check server configuration"
14 | );
15 | });
16 |
17 | it("returns false when keys have different lengths", () => {
18 | expect(isValidInternalKey("short", "much-longer")).toBe(false);
19 | });
20 |
21 | it("returns true when keys match exactly", () => {
22 | expect(isValidInternalKey("same-key", "same-key")).toBe(true);
23 | });
24 |
25 | it("returns false when keys differ but have the same length", () => {
26 | expect(isValidInternalKey("abc123", "abd123")).toBe(false);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/lib/qstash/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview QStash configuration constants per ADR-0048.
3 | */
4 |
5 | import "server-only";
6 |
7 | /**
8 | * QStash retry configuration for webhook job handlers.
9 | * Max 6 total attempts (1 initial + 5 retries) with exponential backoff.
10 | */
11 | export const QSTASH_RETRY_CONFIG = {
12 | /** Initial delay before first retry (QStash uses exponential backoff) */
13 | delay: "10s",
14 | /** Number of retry attempts after initial failure */
15 | retries: 5,
16 | } as const;
17 |
18 | /** Redis key prefix for dead letter queue entries */
19 | export const DLQ_KEY_PREFIX = "qstash-dlq" as const;
20 |
21 | /** TTL for DLQ entries in seconds (7 days) */
22 | export const DLQ_TTL_SECONDS = 60 * 60 * 24 * 7;
23 |
24 | /** Maximum number of DLQ entries to keep per job type */
25 | export const DLQ_MAX_ENTRIES = 1000;
26 |
27 | /** Threshold for DLQ size alerts */
28 | export const DLQ_ALERT_THRESHOLD = 100;
29 |
30 | /** QStash signing key header name */
31 | export const QSTASH_SIGNATURE_HEADER = "upstash-signature" as const;
32 |
--------------------------------------------------------------------------------
/src/components/ui/__mocks__/use-toast.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Vitest mocks for the use-toast module. Provides mocked
3 | * implementations for testing toast functionality in isolation.
4 | */
5 |
6 | import { vi } from "vitest";
7 | import type { Toast } from "../use-toast";
8 |
9 | /**
10 | * Mock implementation of the toast function. Creates a mock toast object
11 | * with jest mock functions for testing purposes.
12 | *
13 | * @param _props - Toast properties (ignored in mock).
14 | * @returns A mock toast object with dismiss, id, and update properties.
15 | */
16 | export const toast = vi.fn((_props: Toast) => ({
17 | dismiss: vi.fn(),
18 | id: `toast-${Date.now()}`,
19 | update: vi.fn(),
20 | }));
21 |
22 | /**
23 | * Mock implementation of the useToast hook. Returns a mock object
24 | * with the expected hook interface for testing.
25 | *
26 | * @returns A mock useToast hook result with dismiss function, toast function, and empty toasts array.
27 | */
28 | export const useToast = vi.fn(() => ({
29 | dismiss: vi.fn(),
30 | toast,
31 | toasts: [],
32 | }));
33 |
--------------------------------------------------------------------------------
/src/app/api/keys/_error-mapping.ts:
--------------------------------------------------------------------------------
1 | import { errorResponse } from "@/lib/api/route-helpers";
2 |
3 | export const PLANNED_ERROR_CODES = {
4 | invalidKey: "INVALID_KEY",
5 | networkError: "NETWORK_ERROR",
6 | vaultUnavailable: "VAULT_UNAVAILABLE",
7 | } as const;
8 |
9 | export type PlannedErrorCode =
10 | (typeof PLANNED_ERROR_CODES)[keyof typeof PLANNED_ERROR_CODES];
11 |
12 | export function vaultUnavailableResponse(reason: string, err?: unknown): Response {
13 | return errorResponse({
14 | err,
15 | error: PLANNED_ERROR_CODES.vaultUnavailable,
16 | reason,
17 | status: 500,
18 | });
19 | }
20 |
21 | export function mapProviderStatusToCode(status: number): PlannedErrorCode {
22 | if (status === 429) return PLANNED_ERROR_CODES.networkError;
23 | if (status >= 500) return PLANNED_ERROR_CODES.networkError;
24 | if (status >= 400) return PLANNED_ERROR_CODES.invalidKey;
25 | return PLANNED_ERROR_CODES.networkError;
26 | }
27 |
28 | export function mapProviderExceptionToCode(_error: unknown): PlannedErrorCode {
29 | return PLANNED_ERROR_CODES.networkError;
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/chat/__tests__/page.smoke.test.tsx:
--------------------------------------------------------------------------------
1 | /** @vitest-environment jsdom */
2 |
3 | import { render, screen } from "@testing-library/react";
4 | import { describe, expect, it, vi } from "vitest";
5 | import ChatPage from "../page";
6 |
7 | // Mock Streamdown-backed Response to avoid rehype/ESM issues in node test runner
8 | vi.mock("@/components/ai-elements/response", () => ({
9 | Response: ({ children }: { children?: React.ReactNode }) => (
10 |
{children}
11 | ),
12 | }));
13 |
14 | // Mock the ChatClient component to avoid importing server-side dependencies
15 | // and AI SDK components that are not needed for this smoke test.
16 | // This allows us to test the page structure without full component initialization.
17 | vi.mock("../chat-client", () => ({
18 | ChatClient: () =>
Chat Client
,
19 | }));
20 |
21 | describe("ChatPage UI smoke", () => {
22 | it("renders the chat client component", () => {
23 | render();
24 | expect(screen.getByTestId("chat-client")).toBeInTheDocument();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/ai/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Shared AI agent constants.
3 | */
4 |
5 | export const CHAT_DEFAULT_SYSTEM_PROMPT = `You are a helpful travel planning assistant with access to many tools.
6 |
7 | When the user asks for something factual, call the matching tool by name:
8 | - Flights: use searchFlights for routes/fares.
9 | - Lodging: use searchAccommodations, getAccommodationDetails, checkAvailability, bookAccommodation (confirm before booking).
10 | - Activities/POIs: use searchActivities / lookupPoiContext; include hours and location.
11 | - Planning: use createTravelPlan / saveTravelPlan to build and store itineraries; confirm before saving.
12 | - Weather: use getCurrentWeather for conditions; include units and location.
13 | - Maps: use geocode for addresses and distanceMatrix for travel times/distances.
14 | - Discovery: use webSearch/webSearchBatch for general research.
15 | - Memory: use searchUserMemories to recall prior trips or preferences.
16 |
17 | Always combine tool outputs into clear recommendations, cite sources when possible, and ask clarifying questions before making bookings or saving plans.`;
18 |
--------------------------------------------------------------------------------
/src/test/factories/chat-factory.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Factory for creating ChatSession and Message test data.
3 | */
4 |
5 | import type { ChatSession, Message } from "@schemas/chat";
6 |
7 | /**
8 | * Create mock chat session.
9 | *
10 | * @param overrides - Partial session to override defaults
11 | * @returns A complete chat session
12 | */
13 | export function createMockSession(overrides: Partial = {}): ChatSession {
14 | return {
15 | agentId: "agent-1",
16 | createdAt: "2025-01-01T00:00:00Z",
17 | id: "session-1",
18 | messages: [],
19 | title: "Test Conversation",
20 | updatedAt: "2025-01-01T00:00:00Z",
21 | ...overrides,
22 | };
23 | }
24 |
25 | /**
26 | * Create mock message.
27 | *
28 | * @param overrides - Partial message to override defaults
29 | * @returns A complete message
30 | */
31 | export function createMockMessage(overrides: Partial = {}): Message {
32 | return {
33 | content: "Test message",
34 | id: "msg-1",
35 | role: "user",
36 | timestamp: "2025-01-01T00:00:00Z",
37 | ...overrides,
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/src/ai/tools/schemas/activities.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Activity tool model output schemas.
3 | */
4 |
5 | import { z } from "zod";
6 |
7 | // ===== MODEL OUTPUT SCHEMAS =====
8 |
9 | /** Activity entry for model consumption. Fields match source activitySchema requirements. */
10 | const activityEntryModelOutputSchema = z.strictObject({
11 | duration: z.number(),
12 | id: z.string(),
13 | location: z.string(),
14 | name: z.string(),
15 | price: z.number(),
16 | rating: z.number(),
17 | type: z.string(),
18 | });
19 |
20 | /** Activity search result metadata for model consumption. */
21 | const activityMetadataModelOutputSchema = z.strictObject({
22 | primarySource: z.enum(["googleplaces", "ai_fallback", "mixed"]),
23 | total: z.number().int(),
24 | });
25 |
26 | /** Activity search result output schema for model consumption. */
27 | export const activityModelOutputSchema = z.strictObject({
28 | activities: z.array(activityEntryModelOutputSchema),
29 | metadata: activityMetadataModelOutputSchema,
30 | });
31 |
32 | export type ActivityModelOutput = z.infer;
33 |
--------------------------------------------------------------------------------
/src/app/api/flights/search/route.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Flight search API route.
3 | *
4 | * POST /api/flights/search
5 | * Searches for flights using Duffel provider.
6 | */
7 |
8 | import "server-only";
9 |
10 | import { searchFlightsService } from "@domain/flights/service";
11 | import { flightSearchRequestSchema } from "@schemas/flights";
12 | import { withApiGuards } from "@/lib/api/factory";
13 |
14 | export const POST = withApiGuards({
15 | auth: false, // Allow anonymous searches
16 | rateLimit: "flights:search",
17 | schema: flightSearchRequestSchema,
18 | telemetry: "flights.search",
19 | })(async (_req, _ctx, body) => {
20 | // Note: The flights service performs its own validation via
21 | // flightSearchRequestSchema.parse(params) because it is invoked from multiple
22 | // entry points (including AI tools) that bypass withApiGuards. It also
23 | // intentionally omits ServiceContext/userId and rate limiting at the service
24 | // layer—unlike accommodations—by API design.
25 | const result = await searchFlightsService(body);
26 |
27 | return Response.json(result);
28 | });
29 |
--------------------------------------------------------------------------------
/src/test/helpers/make-request.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Shared test helper for creating minimal NextRequest mocks.
3 | *
4 | * Use this helper when testing route handlers that only need to access
5 | * request headers. For tests requiring full NextRequest functionality,
6 | * consider a more complete mock or integration tests.
7 | */
8 |
9 | import type { NextRequest } from "next/server";
10 | import { unsafeCast } from "./unsafe-cast";
11 |
12 | /**
13 | * Creates a minimal NextRequest mock with only headers populated.
14 | *
15 | * @param headers - Optional headers to include in the mock request.
16 | * @returns A minimal NextRequest mock suitable for testing header-only access.
17 | *
18 | * @example
19 | * ```typescript
20 | * const req = makeRequest({ "x-real-ip": "192.168.1.1" });
21 | * expect(getClientIpFromHeaders(req)).toBe("192.168.1.1");
22 | * ```
23 | *
24 | * @internal Only use where headers are the sole accessed property.
25 | */
26 | export function makeRequest(headers: HeadersInit = {}): NextRequest {
27 | return unsafeCast({ headers: new Headers(headers) });
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as SonnerToaster } from "sonner";
5 |
6 | /**
7 | * Toast notification container using Sonner.
8 | *
9 | * Integrates with next-themes for automatic light/dark theme support.
10 | * Renders at bottom-right on desktop, top on mobile.
11 | */
12 | export function Toaster() {
13 | const { theme = "system" } = useTheme();
14 |
15 | return (
16 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/lib/client/session.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Client-side session utilities.
3 | * Uses browser storage APIs - must only be imported in client components.
4 | */
5 |
6 | "use client";
7 |
8 | import { secureUuid } from "@/lib/security/random";
9 |
10 | /**
11 | * Get or create a per-session identifier for error tracking and telemetry.
12 | *
13 | * The ID is stored in `sessionStorage` under the key `session_id`. If it does
14 | * not exist, a new ID is generated using `secureUuid()` and persisted. When
15 | * called in environments without access to `sessionStorage` (e.g., server
16 | * rendering, certain privacy contexts), the function returns `undefined`.
17 | *
18 | * @returns A stable session identifier string or `undefined` when unavailable.
19 | */
20 | export function getSessionId(): string | undefined {
21 | try {
22 | let sessionId = sessionStorage.getItem("session_id");
23 | if (!sessionId) {
24 | sessionId = `session_${secureUuid()}`;
25 | sessionStorage.setItem("session_id", sessionId);
26 | }
27 | return sessionId;
28 | } catch {
29 | return undefined;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/stores/ui/navigation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Navigation slice for UI store.
3 | */
4 |
5 | import type { StateCreator } from "zustand";
6 | import type { NavigationSlice, NavigationState, UiState } from "./types";
7 |
8 | export const DEFAULT_NAVIGATION_STATE: NavigationState = {
9 | activeRoute: "/",
10 | breadcrumbs: [],
11 | };
12 |
13 | export const createNavigationSlice: StateCreator = (
14 | set
15 | ) => ({
16 | addBreadcrumb: (breadcrumb) => {
17 | set((state) => ({
18 | navigation: {
19 | ...state.navigation,
20 | breadcrumbs: [...state.navigation.breadcrumbs, breadcrumb],
21 | },
22 | }));
23 | },
24 | navigation: DEFAULT_NAVIGATION_STATE,
25 |
26 | setActiveRoute: (route) => {
27 | set((state) => ({
28 | navigation: {
29 | ...state.navigation,
30 | activeRoute: route,
31 | },
32 | }));
33 | },
34 |
35 | setBreadcrumbs: (breadcrumbs) => {
36 | set((state) => ({
37 | navigation: {
38 | ...state.navigation,
39 | breadcrumbs,
40 | },
41 | }));
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/src/lib/__tests__/zod-v4-resolver.dom.test.tsx:
--------------------------------------------------------------------------------
1 | /** @vitest-environment jsdom */
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { render } from "@testing-library/react";
5 | import React from "react";
6 | import { useForm } from "react-hook-form";
7 | import { describe, expect, it } from "vitest";
8 | import { z } from "zod";
9 |
10 | function FormHarness() {
11 | const schema = z.object({ email: z.email() });
12 | const { handleSubmit, register } = useForm<{ email: string }>({
13 | resolver: zodResolver(schema as never),
14 | });
15 | const onSubmit = () => {
16 | // Empty submit handler for test
17 | };
18 | return (
19 |
23 | );
24 | }
25 |
26 | describe("zod v4 resolver interop", () => {
27 | it("mounts a form with zodResolver without runtime errors", () => {
28 | const { getByText } = render(React.createElement(FormHarness));
29 | expect(getByText("Submit")).toBeInTheDocument();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/docs/architecture/decisions/adr-0033-rag-advanced-v6.md:
--------------------------------------------------------------------------------
1 | # ADR: RAG Advanced with AI SDK v6 (Hybrid + Provider Reranking)
2 |
3 | ## Status
4 |
5 | Proposed
6 |
7 | ## Context
8 |
9 | We need a robust RAG pipeline on Next.js 16 using AI SDK v6. Python RAG remains in FastAPI; we will unify in TypeScript with Supabase pgvector, hybrid retrieval (vector + keyword), and provider reranking (e.g., Cohere).
10 |
11 | ## Decision
12 |
13 | - Adopt hybrid retrieval in TS and apply provider reranking (e.g., cohere.reranking('rerank-v3.5')) for top-k refinement. Provider-dependent feature.
14 | - Cache frequent queries with short TTL via Upstash.
15 | - Ensure v6 UIMessage.parts alignment for returned context and references.
16 |
17 | ## Consequences
18 |
19 | - Improved relevance and maintainability; single Next backend.
20 | - Adds provider dependency; must handle fallback if rerank unavailable.
21 |
22 | ## References
23 |
24 | - v6 Reranking:
25 | - Embeddings:
26 | - Next.js App Router:
27 |
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Next.js instrumentation hook for OpenTelemetry server-side tracing.
3 | *
4 | * This file is automatically executed by Next.js before the application starts.
5 | * It initializes @vercel/otel to enable automatic instrumentation of Route Handlers,
6 | * Server Components, and Middleware.
7 | */
8 |
9 | import { registerOTel } from "@vercel/otel";
10 | import { TELEMETRY_SERVICE_NAME } from "@/lib/telemetry/constants";
11 |
12 | /**
13 | * Registers OpenTelemetry instrumentation for the Next.js application.
14 | *
15 | * This function is called by Next.js during application startup to enable
16 | * server-side tracing. The @vercel/otel wrapper handles all the complexity
17 | * of setting up NodeSDK, resource detection, and Next.js-specific instrumentation.
18 | */
19 | export async function register() {
20 | registerOTel({
21 | serviceName: TELEMETRY_SERVICE_NAME,
22 | });
23 |
24 | // Initialize security modules on server runtime only
25 | if (process.env.NEXT_RUNTIME === "nodejs") {
26 | const { initMfa } = await import("@/lib/security/mfa");
27 | initMfa();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/routes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Centralized application routes.
3 | *
4 | * Provides a single source of truth for all internal routes to ensure
5 | * consistency across the application.
6 | */
7 |
8 | export const ROUTES = {
9 | // Demo route
10 | aiDemo: "/ai-demo",
11 |
12 | // Chat
13 | chat: "/chat",
14 | contact: "/contact",
15 |
16 | // Dashboard routes
17 | dashboard: {
18 | agentStatus: "/dashboard/agent-status",
19 | billing: "/dashboard/billing",
20 | calendar: "/dashboard/calendar",
21 | profile: "/dashboard/profile",
22 | root: "/dashboard",
23 | search: "/dashboard/search",
24 | security: "/dashboard/security",
25 | settings: "/dashboard/settings",
26 | settingsApiKeys: "/dashboard/settings/api-keys",
27 | team: "/dashboard/team",
28 | trips: "/dashboard/trips",
29 | },
30 |
31 | // Root
32 | home: "/",
33 | // Auth routes
34 | login: "/login",
35 |
36 | // Legal routes
37 | privacy: "/privacy",
38 | register: "/register",
39 |
40 | // Search results
41 | searchFlightsResults: "/search/flights/results",
42 | terms: "/terms",
43 | } as const;
44 |
--------------------------------------------------------------------------------
/src/styles/streamdown.css:
--------------------------------------------------------------------------------
1 | /* Streamdown chat-specific styling overrides.
2 | *
3 | * Streamdown already ships shadcn/ui-aligned styles. These rules gently
4 | * tighten spacing and borders to match TripSage chat surfaces.
5 | */
6 |
7 | .streamdown-chat [data-streamdown="code-block"],
8 | .streamdown-chat [data-streamdown="mermaid-block"] {
9 | border: 1px solid hsl(var(--border));
10 | border-radius: calc(var(--radius) + 2px);
11 | background-color: hsl(var(--muted));
12 | }
13 |
14 | .streamdown-chat [data-streamdown="inline-code"] {
15 | background-color: hsl(var(--muted));
16 | border-radius: 0.25rem;
17 | padding: 0.1rem 0.25rem;
18 | }
19 |
20 | .streamdown-chat [data-streamdown="table-wrapper"] {
21 | border: 1px solid hsl(var(--border));
22 | border-radius: var(--radius);
23 | overflow: hidden;
24 | }
25 |
26 | .streamdown-chat [data-streamdown="blockquote"] {
27 | background-color: hsl(var(--muted));
28 | border-left: 3px solid hsl(var(--primary));
29 | border-radius: var(--radius);
30 | padding: 0.75rem 1rem;
31 | }
32 |
33 | .streamdown-chat [data-streamdown^="heading-"] {
34 | scroll-margin-top: 6rem;
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/auth/me/route.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Auth session introspection route.
3 | *
4 | * Returns the current authenticated user in the frontend AuthUser shape using
5 | * Supabase SSR cookies as the session source of truth.
6 | */
7 |
8 | import "server-only";
9 |
10 | import type { AuthUser } from "@schemas/stores";
11 | import type { NextRequest } from "next/server";
12 | import { NextResponse } from "next/server";
13 | import { getOptionalUser, mapSupabaseUserToAuthUser } from "@/lib/auth/server";
14 |
15 | interface MeResponse {
16 | user: AuthUser | null;
17 | }
18 |
19 | /**
20 | * Handles GET /auth/me.
21 | *
22 | * When authenticated, returns `{ user }` with the mapped AuthUser shape.
23 | * When unauthenticated, returns `{ user: null }` with HTTP 401.
24 | */
25 | export async function GET(_req: NextRequest): Promise> {
26 | const { user } = await getOptionalUser();
27 |
28 | if (!user) {
29 | return NextResponse.json({ user: null }, { status: 401 });
30 | }
31 |
32 | const mappedUser = mapSupabaseUserToAuthUser(user);
33 | return NextResponse.json({ user: mappedUser }, { status: 200 });
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Supabase, Inc. and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/lib/geo.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Geographic distance calculation utilities.
3 | */
4 |
5 | /** Earth's radius in kilometers. */
6 | const EARTH_RADIUS_KM = 6371;
7 |
8 | /** Coordinates interface. */
9 | export interface Coordinates {
10 | lat: number;
11 | lng: number;
12 | }
13 |
14 | /**
15 | * Calculates the great-circle distance between two points using the Haversine formula.
16 | *
17 | * @param from - Origin coordinates.
18 | * @param to - Destination coordinates.
19 | * @returns Distance in kilometers.
20 | */
21 | export function calculateDistanceKm(from: Coordinates, to: Coordinates): number {
22 | const lat1Rad = (from.lat * Math.PI) / 180;
23 | const lat2Rad = (to.lat * Math.PI) / 180;
24 | const deltaLat = ((to.lat - from.lat) * Math.PI) / 180;
25 | const deltaLng = ((to.lng - from.lng) * Math.PI) / 180;
26 |
27 | const a =
28 | Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
29 | Math.cos(lat1Rad) *
30 | Math.cos(lat2Rad) *
31 | Math.sin(deltaLng / 2) *
32 | Math.sin(deltaLng / 2);
33 |
34 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
35 |
36 | return EARTH_RADIUS_KM * c;
37 | }
38 |
--------------------------------------------------------------------------------
/src/test/helpers/store.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Centralized store test helpers.
3 | *
4 | * Provides reusable utilities for testing Zustand stores:
5 | * - Timeout/timer mocking for deterministic async flows
6 | */
7 |
8 | import { vi } from "vitest";
9 |
10 | /**
11 | * Mock setTimeout to execute immediately in tests.
12 | * Returns cleanup function.
13 | *
14 | * @returns Object with mockRestore function
15 | */
16 | export function setupTimeoutMock(): { mockRestore: () => void } {
17 | const originalSetTimeout = globalThis.setTimeout;
18 |
19 | const timeoutSpy = vi
20 | .spyOn(globalThis, "setTimeout")
21 | .mockImplementation((cb: TimerHandler, _ms?: number, ...args: unknown[]) => {
22 | if (typeof cb === "function") {
23 | cb(...(args as never[]));
24 | }
25 | const handle = originalSetTimeout(() => undefined, 0);
26 | if (typeof handle === "object" && handle && "unref" in handle) {
27 | (handle as { unref?: () => void }).unref?.();
28 | }
29 | return handle;
30 | });
31 |
32 | return {
33 | mockRestore: () => {
34 | timeoutSpy.mockRestore();
35 | },
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/api/__tests__/factory.body-limit.test.ts:
--------------------------------------------------------------------------------
1 | /** @vitest-environment node */
2 |
3 | import { NextRequest } from "next/server";
4 | import { describe, expect, it } from "vitest";
5 | import { z } from "zod";
6 | import { withApiGuards } from "@/lib/api/factory";
7 |
8 | describe("withApiGuards body limits", () => {
9 | it("honors maxBodyBytes override for schema parsing", async () => {
10 | const handler = withApiGuards({
11 | maxBodyBytes: 10,
12 | schema: z.strictObject({ ok: z.boolean() }),
13 | })(async () => new Response("ok", { status: 200 }));
14 |
15 | const stream = new ReadableStream({
16 | start(controller) {
17 | controller.enqueue(new TextEncoder().encode(JSON.stringify({ ok: true })));
18 | controller.close();
19 | },
20 | });
21 |
22 | const req = new NextRequest("https://example.com/api/test", {
23 | body: stream,
24 | duplex: "half",
25 | headers: { "content-type": "application/json" },
26 | method: "POST",
27 | });
28 |
29 | const res = await handler(req, { params: Promise.resolve({}) });
30 | expect(res.status).toBe(413);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/app/api/security/events/route.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Security events API. Returns recent auth audit events for the current user.
3 | */
4 |
5 | import "server-only";
6 |
7 | import { type NextRequest, NextResponse } from "next/server";
8 | import { withApiGuards } from "@/lib/api/factory";
9 | import { requireUserId } from "@/lib/api/route-helpers";
10 | import { getUserSecurityEvents } from "@/lib/security/service";
11 | import { createAdminSupabase } from "@/lib/supabase/admin";
12 |
13 | /**
14 | * GET handler for the security events API.
15 | *
16 | * @param _req - The Next.js request object.
17 | * @param user - The authenticated user.
18 | * @returns The security events.
19 | */
20 | export const GET = withApiGuards({
21 | auth: true,
22 | rateLimit: "security:events",
23 | telemetry: "security.events",
24 | })(async (_req: NextRequest, { user }) => {
25 | const result = requireUserId(user);
26 | if ("error" in result) return result.error;
27 | const { userId } = result;
28 |
29 | const adminSupabase = createAdminSupabase();
30 | const events = await getUserSecurityEvents(adminSupabase, userId);
31 | return NextResponse.json(events);
32 | });
33 |
--------------------------------------------------------------------------------
/src/app/dashboard/loading.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Dashboard loading skeleton.
3 | */
4 |
5 | "use client";
6 |
7 | import { CardSkeleton, LoadingContainer } from "@/components/ui/loading";
8 |
9 | /**
10 | * Dashboard loading component
11 | * Shows skeleton for dashboard layout while content loads
12 | */
13 | export default function DashboardLoading() {
14 | return (
15 |
21 | {/* Dashboard grid skeleton */}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/features/search/cards/rating-stars.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Rating stars component for displaying activity ratings.
3 | */
4 |
5 | import { StarIcon } from "lucide-react";
6 |
7 | /**
8 | * Rating stars component for displaying activity ratings.
9 | *
10 | * @param value - The rating value to display.
11 | * @param max - The maximum number of stars to display.
12 | * @returns The rating stars component.
13 | */
14 | export function RatingStars({ value, max = 5 }: { value: number; max?: number }) {
15 | const roundedValue = Math.round(value);
16 | const stars = Array.from({ length: max }, (_, idx) => ({
17 | filled: idx < roundedValue,
18 | key: `star-${idx + 1}`,
19 | }));
20 |
21 | return (
22 |