From 2c30572ae9859c8752e0c0a268eceecd5d2f05ef Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Wed, 24 Jun 2026 03:33:03 +0300 Subject: [PATCH 1/2] feat(web): set up tRPC v11 API layer with React Query v5 Closes #36 - Install @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod - Scaffold server/trpc.ts: TRPCContext, publicProcedure, protectedProcedure (Bearer/cookie auth), createCallerFactory - Add routers: health (proxies Go backend), auth (session/signOut stubs), notifications (FCM token stub) - Wire /api/trpc/[trpc] fetchRequestHandler (GET + POST) - Add lib/trpc/client.tsx: TRPCProvider wrapping QueryClientProvider - Add lib/trpc/server.ts: cached createServerCaller for Server Components - Wire TRPCProvider via app/providers.tsx into root layout - Add Vitest tests for health router and protectedProcedure (UNAUTHORIZED, Bearer, cookie) - Pin postgres:16 in docker-compose.yml - Update docs: styling.md and components.md (shadcn/ui), data-fetching.md (tRPC section), new trpc.md --- backend/docker-compose.yml | 2 +- web/AGENTS.md | 1 + web/app/api/trpc/[trpc]/route.ts | 14 + web/app/layout.tsx | 5 +- web/app/page.tsx | 10 +- web/app/providers.tsx | 7 + web/components/home/about.tsx | 5 + web/components/home/hero.tsx | 5 + web/docs/_index.md | 3 +- web/docs/components.md | 120 +++++++-- web/docs/data-fetching.md | 42 ++- web/docs/styling.md | 104 +++++-- web/docs/trpc.md | 283 ++++++++++++++++++++ web/lib/trpc/client.tsx | 34 +++ web/lib/trpc/server.ts | 17 ++ web/package.json | 7 +- web/pnpm-lock.yaml | 69 +++++ web/server/routers/__tests__/auth.test.ts | 32 +++ web/server/routers/__tests__/health.test.ts | 36 +++ web/server/routers/_app.ts | 12 + web/server/routers/auth.ts | 12 + web/server/routers/health.ts | 11 + web/server/routers/notifications.ts | 15 ++ web/server/trpc.ts | 31 +++ 24 files changed, 813 insertions(+), 64 deletions(-) create mode 100644 web/app/api/trpc/[trpc]/route.ts create mode 100644 web/app/providers.tsx create mode 100644 web/components/home/about.tsx create mode 100644 web/components/home/hero.tsx create mode 100644 web/docs/trpc.md create mode 100644 web/lib/trpc/client.tsx create mode 100644 web/lib/trpc/server.ts create mode 100644 web/server/routers/__tests__/auth.test.ts create mode 100644 web/server/routers/__tests__/health.test.ts create mode 100644 web/server/routers/_app.ts create mode 100644 web/server/routers/auth.ts create mode 100644 web/server/routers/health.ts create mode 100644 web/server/routers/notifications.ts create mode 100644 web/server/trpc.ts diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 24a286f..b5fa65b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,6 +1,6 @@ services: psql_bp: - image: postgres:latest + image: postgres:16 restart: unless-stopped environment: POSTGRES_DB: ${BLUEPRINT_DB_DATABASE} diff --git a/web/AGENTS.md b/web/AGENTS.md index 3033cd6..aedba6e 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -56,6 +56,7 @@ Read the relevant doc before implementing. These are kept in sync with the code |---|---| | App Router, route files, layouts, navigation | [`docs/routing.md`](docs/routing.md) | | Server Components, data fetching, Server Actions | [`docs/data-fetching.md`](docs/data-fetching.md) | +| tRPC routers, React Query, client/server usage | [`docs/trpc.md`](docs/trpc.md) | | Tailwind CSS v4, theme tokens, dark mode | [`docs/styling.md`](docs/styling.md) | | Component conventions, TypeScript rules | [`docs/components.md`](docs/components.md) | | Testing setup, patterns, what to test | [`docs/testing.md`](docs/testing.md) | diff --git a/web/app/api/trpc/[trpc]/route.ts b/web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..4e7a37b --- /dev/null +++ b/web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,14 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch' +import { type NextRequest } from 'next/server' +import { createTRPCContext } from '@/server/trpc' +import { appRouter } from '@/server/routers/_app' + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: () => createTRPCContext({ req }), + }) + +export { handler as GET, handler as POST } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 976eb90..5068e0a 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Providers } from "./providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +28,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/web/app/page.tsx b/web/app/page.tsx index 2b72e02..5237f3a 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,7 +1,13 @@ +import About from "@/components/home/about"; +import Hero from "@/components/home/hero"; + const page = () => { return ( -
+
+ + +
); }; -export default page; \ No newline at end of file +export default page; diff --git a/web/app/providers.tsx b/web/app/providers.tsx new file mode 100644 index 0000000..12464c3 --- /dev/null +++ b/web/app/providers.tsx @@ -0,0 +1,7 @@ +'use client' + +import { TRPCProvider } from '@/lib/trpc/client' + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/web/components/home/about.tsx b/web/components/home/about.tsx new file mode 100644 index 0000000..3d8ddaa --- /dev/null +++ b/web/components/home/about.tsx @@ -0,0 +1,5 @@ +const About = () => { + return
; +}; + +export default About; diff --git a/web/components/home/hero.tsx b/web/components/home/hero.tsx new file mode 100644 index 0000000..6d4154b --- /dev/null +++ b/web/components/home/hero.tsx @@ -0,0 +1,5 @@ +const Hero = () => { + return
; +}; + +export default Hero; diff --git a/web/docs/_index.md b/web/docs/_index.md index 08f0615..c6590df 100644 --- a/web/docs/_index.md +++ b/web/docs/_index.md @@ -6,7 +6,8 @@ The `docs` agent reads this index first to locate the right file. | Topic | File | Source files covered | |---|---|---| | App Router structure & route conventions | [routing.md](routing.md) | `app/layout.tsx`, `app/page.tsx`, `next.config.ts` | -| Data fetching patterns | [data-fetching.md](data-fetching.md) | `app/page.tsx`, `app/layout.tsx` | +| Data fetching patterns | [data-fetching.md](data-fetching.md) | `app/page.tsx`, `app/layout.tsx`, `lib/trpc/server.ts`, `lib/trpc/client.tsx` | +| tRPC v11 + React Query v5 — routers, context, client/server usage | [trpc.md](trpc.md) | `server/trpc.ts`, `server/routers/_app.ts`, `lib/trpc/client.tsx`, `lib/trpc/server.ts`, `app/providers.tsx` | | Styling with Tailwind CSS v4 | [styling.md](styling.md) | `app/globals.css`, `postcss.config.mjs` | | Component conventions | [components.md](components.md) | `app/` (all component files) | | Testing patterns | [testing.md](testing.md) | `vitest.config.ts`, `vitest.setup.ts`, `__tests__/page.test.tsx` | diff --git a/web/docs/components.md b/web/docs/components.md index e703284..a207f44 100644 --- a/web/docs/components.md +++ b/web/docs/components.md @@ -1,9 +1,16 @@ --- topic: components -last_verified: 2026-06-14 +last_verified: 2026-06-23 sources: - - app/layout.tsx - - app/page.tsx + - components/ui/button.tsx + - components/common/container.tsx + - components/common/h1.tsx + - components/common/h2.tsx + - components/common/h3.tsx + - components/common/loader.tsx + - components/home/hero.tsx + - components/home/about.tsx + - lib/utils.ts --- # Component Conventions @@ -21,44 +28,99 @@ Only when you need: Do not add `"use client"` to layouts, pages, or wrapper components just because a child needs it — push `"use client"` down to the smallest possible component. -## File placement +## Directory structure ``` -app/ # route files only (page.tsx, layout.tsx, loading.tsx, error.tsx) -components/ # shared reusable components - ui/ # generic primitives (Button, Input, Modal, etc.) - [feature]/ # feature-specific components (e.g., components/auth/) -lib/ # utility functions, helpers, non-React code -types/ # shared TypeScript type definitions +components/ + ui/ — shadcn primitives (avatar, badge, button, card, dialog, dropdown-menu, + input, label, separator, skeleton, sonner) + common/ — cross-feature reusable UI (container, h1, h2, h3, loader) + home/ — feature-scoped components (hero, about) + layout/ — layout wrappers (navbar, footer, etc.) +lib/ + utils.ts — cn() utility and other non-React helpers +types/ — shared TypeScript type definitions ``` -## Component file structure +Route files (`page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`) live in `app/` only. + +## `cn()` utility +All conditional class merging uses `cn()` from `lib/utils.ts`: +```ts +// lib/utils.ts +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +``` +Import it as `import { cn } from "@/lib/utils"` in every component that merges classes. + +## `components/ui/` — shadcn primitives +Generated by shadcn/ui. Use `class-variance-authority` (`cva`) for variant logic and `Slot` from `radix-ui` for the `asChild` pattern. Do not hand-edit these files directly; re-run the shadcn CLI to update them. + +Pattern from `button.tsx`: ```tsx -// components/ui/Button.tsx -import type { ButtonHTMLAttributes } from 'react'; +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" +import { cn } from "@/lib/utils" -type ButtonProps = ButtonHTMLAttributes & { - variant?: 'primary' | 'secondary'; -}; +const buttonVariants = cva("", { + variants: { + variant: { default: "...", outline: "...", secondary: "...", ghost: "...", destructive: "...", link: "..." }, + size: { default: "...", xs: "...", sm: "...", lg: "...", icon: "...", "icon-xs": "...", "icon-sm": "...", "icon-lg": "..." }, + }, + defaultVariants: { variant: "default", size: "default" }, +}) -export function Button({ variant = 'primary', children, ...props }: ButtonProps) { +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" return ( - - ); + /> + ) } + +export { Button, buttonVariants } +``` + +## `components/common/` — hand-crafted cross-feature components +These are hand-written and use `cn()` with Tailwind classes directly. They do not use `cva`. + +`Container` — constrains max width and centers content: +```tsx +const Container = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+) ``` +`H1`, `H2`, `H3` — semantic heading wrappers that forward a `className` prop through `cn()`. `H2` makes `className` optional; `H1` and `H3` require it. + +`Loader` — accepts a required `className` prop and renders a `div` with those classes applied via `cn()`. + +## `components/home/` — feature-scoped components +Components specific to the home feature. Scoped to avoid polluting `common/`. + ## TypeScript rules - No `any`. Use proper interfaces or `unknown`. -- Extend native HTML element types (`ButtonHTMLAttributes`, `InputHTMLAttributes`) for wrapper components. -- Keep component prop types in the same file as the component unless shared across multiple files. -- Shared types → `types/` directory. +- For shadcn primitives, extend `React.ComponentProps<"element">` (not `ButtonHTMLAttributes`) and combine with `VariantProps`. +- For common/feature components, inline prop types in the same file unless the type is shared across multiple files — then move it to `types/`. +- Import types with `import type` to keep runtime bundles clean. +- React 19: JSX transform is automatic — no `import React from "react"` needed unless you reference `React.*` directly. ## Imports -- Use TypeScript path aliases from `tsconfig.json` — check it before writing relative import chains. -- Import React only when needed (React 19 — JSX transform is automatic, no `import React` needed). -- Import types with `import type` to keep runtime bundles clean. +Use TypeScript path aliases (`@/`) from `tsconfig.json`. Do not write deep relative import chains. diff --git a/web/docs/data-fetching.md b/web/docs/data-fetching.md index 6e164ad..f801264 100644 --- a/web/docs/data-fetching.md +++ b/web/docs/data-fetching.md @@ -1,9 +1,15 @@ --- topic: data-fetching -last_verified: 2026-06-14 +last_verified: 2026-06-24 sources: - app/page.tsx - app/layout.tsx + - server/trpc.ts + - server/routers/_app.ts + - server/routers/health.ts + - lib/trpc/client.tsx + - lib/trpc/server.ts + - app/providers.tsx --- # Data Fetching @@ -71,3 +77,37 @@ Store in an env var for production: `process.env.NEXT_PUBLIC_API_URL` (client-ac ## Caching (Next.js 16) Next.js 16 changes default caching behavior from v14. Check `web/node_modules/next/dist/docs/` for the current defaults before assuming cached or uncached behavior. + +## tRPC (preferred for typed API calls) + +For calls to the Go backend that benefit from end-to-end type safety, use tRPC instead of bare `fetch`. + +**When to use tRPC vs. plain `fetch`:** +- Use tRPC when calling procedures already defined in `server/routers/` — you get compile-time type inference and no manual `res.json()` casting. +- Use plain `fetch` for one-off external APIs, webhooks, or cases where a tRPC router would be disproportionate overhead. + +**Server Components** — use `createServerCaller` from `lib/trpc/server.ts`: +```tsx +import { createServerCaller } from '@/lib/trpc/server' + +export default async function HealthPage() { + const caller = await createServerCaller() + const health = await caller.health.query() + return

Status: {health.status}

+} +``` + +**Client Components** — use `trpc...useQuery()` or `useMutation()` from `lib/trpc/client.tsx`: +```tsx +'use client' +import { trpc } from '@/lib/trpc/client' + +export function HealthStatus() { + const { data, isLoading, isError } = trpc.health.query.useQuery() + if (isLoading) return

Loading…

+ if (isError) return

Error

+ return

Status: {data?.status}

+} +``` + +See [`docs/trpc.md`](trpc.md) for full setup details, context, protected procedures, and how to add new routers. diff --git a/web/docs/styling.md b/web/docs/styling.md index d239e9b..065da20 100644 --- a/web/docs/styling.md +++ b/web/docs/styling.md @@ -1,6 +1,6 @@ --- topic: styling -last_verified: 2026-06-14 +last_verified: 2026-06-23 sources: - app/globals.css - postcss.config.mjs @@ -11,46 +11,94 @@ sources: ## Framework Tailwind CSS v4. No `tailwind.config.js` — v4 uses CSS-first configuration. -## Import +## Imports +`app/globals.css` imports three stylesheets in this order: ```css -/* app/globals.css — must be imported in app/layout.tsx */ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; ``` -## Theme customization (CSS variables via `@theme inline`) -Semantic design tokens are defined in `globals.css` using `@theme inline`: +`globals.css` must be imported in `app/layout.tsx`. + +## Dark mode +Dark mode uses the `.dark` class selector, not a media query. The custom variant is declared at the top of `globals.css`: ```css -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +@custom-variant dark (&:is(.dark *)); +``` +Add the `dark` class to the `` element to activate dark mode. Do not use `@media (prefers-color-scheme: dark)`. + +## Theme tokens (`@theme inline`) +All design tokens are declared in `@theme inline` in `globals.css`. They map Tailwind utility names to the raw CSS custom properties defined in `:root` and `.dark`: + +**Color tokens** (each has a `-foreground` counterpart where applicable): +- `--color-background`, `--color-foreground` +- `--color-card`, `--color-card-foreground` +- `--color-popover`, `--color-popover-foreground` +- `--color-primary`, `--color-primary-foreground` +- `--color-secondary`, `--color-secondary-foreground` +- `--color-muted`, `--color-muted-foreground` +- `--color-accent`, `--color-accent-foreground` +- `--color-destructive` +- `--color-border`, `--color-input`, `--color-ring` +- `--color-chart-1` through `--color-chart-5` +- `--color-sidebar`, `--color-sidebar-foreground`, `--color-sidebar-primary`, `--color-sidebar-primary-foreground`, `--color-sidebar-accent`, `--color-sidebar-accent-foreground`, `--color-sidebar-border`, `--color-sidebar-ring` + +**Radius tokens** (derived from base `--radius: 0.625rem`): +- `--radius-sm` = `calc(var(--radius) * 0.6)` +- `--radius-md` = `calc(var(--radius) * 0.8)` +- `--radius-lg` = `var(--radius)` +- `--radius-xl` = `calc(var(--radius) * 1.4)` +- `--radius-2xl` = `calc(var(--radius) * 1.8)` +- `--radius-3xl` = `calc(var(--radius) * 2.2)` +- `--radius-4xl` = `calc(var(--radius) * 2.6)` + +**Font tokens:** +- `--font-sans: var(--font-sans)` +- `--font-mono: var(--font-geist-mono)` +- `--font-heading: var(--font-sans)` + +## Color space +All raw color values in `:root` and `.dark` use `oklch(...)`. Example: +```css +:root { + --primary: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); } ``` -These tokens become Tailwind utilities: `bg-background`, `text-foreground`, `font-sans`, `font-mono`. -## Dark mode -CSS variable swap via media query in `globals.css`: +## Base layer +`@layer base` in `globals.css` applies defaults globally: ```css -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; } } ``` -Use `bg-background` and `text-foreground` Tailwind classes — they automatically follow dark mode. - -## Fonts -Geist Sans and Geist Mono loaded in `layout.tsx` via `next/font/google`. -They inject `--font-geist-sans` and `--font-geist-mono` CSS variables. -These are wired into Tailwind's `font-sans` and `font-mono` utilities via `@theme inline`. -## Rules -- Tailwind classes only — no CSS modules, no styled-components, no inline `style={}`. -- Add new design tokens in `globals.css` under `@theme inline`, not in a config file. -- Use semantic tokens (`bg-background`, `text-foreground`) over raw colors (`bg-white`, `text-gray-900`) so dark mode works automatically. -- For component-specific styles that can't be handled by Tailwind, add to `globals.css` with a clear comment. Keep it minimal. +## Toaster theming +The `.toaster` block wires shadcn/sonner toast colors to the shared token system: +```css +.toaster { + --normal-bg: var(--popover); + --normal-text: var(--popover-foreground); + --normal-border: var(--border); + --border-radius: var(--radius); +} +``` ## PostCSS Config in `postcss.config.mjs`. Uses `@tailwindcss/postcss` plugin. Do not modify unless adding a non-Tailwind PostCSS plugin. + +## Rules +- Tailwind classes only — no CSS modules, no styled-components, no inline `style={}`. +- New design tokens go in `globals.css` under `@theme inline`, not in a config file. +- Always use semantic tokens (`bg-background`, `text-foreground`, `bg-primary`) over raw color utilities (`bg-white`, `text-gray-900`) so dark mode works automatically. +- Do not use `@media (prefers-color-scheme: dark)` — the project uses class-based dark mode exclusively. diff --git a/web/docs/trpc.md b/web/docs/trpc.md new file mode 100644 index 0000000..e74b286 --- /dev/null +++ b/web/docs/trpc.md @@ -0,0 +1,283 @@ +--- +topic: trpc +last_verified: 2026-06-24 +sources: + - server/trpc.ts + - server/routers/_app.ts + - server/routers/health.ts + - server/routers/auth.ts + - server/routers/notifications.ts + - app/api/trpc/[trpc]/route.ts + - lib/trpc/client.tsx + - lib/trpc/server.ts + - app/providers.tsx +--- + +# tRPC + +tRPC v11 with React Query v5, wired into Next.js App Router. + +## Package setup + +```json +"@trpc/server": "^11.18.0", +"@trpc/client": "^11.18.0", +"@trpc/react-query": "^11.18.0", +"@tanstack/react-query": "^5.101.1", +"zod": "^4.4.3" +``` + +## Context and initialization (`server/trpc.ts`) + +```ts +export interface TRPCContext { + req: NextRequest + // session will be added when auth is implemented +} + +export async function createTRPCContext({ req }: { req: NextRequest }): Promise +``` + +`createTRPCContext` takes a `NextRequest` and returns the context object passed to every procedure. + +### Procedure types + +**`publicProcedure`** — alias for `t.procedure`. No auth check. + +**`protectedProcedure`** — middleware runs before the handler: +- Reads `Authorization` header: passes if it starts with `Bearer `. +- Reads `Cookie` header: passes if it contains `__session=`. +- Throws `TRPCError({ code: 'UNAUTHORIZED' })` if neither is present. + +**`createCallerFactory`** — exported from `t.createCallerFactory`; used by `lib/trpc/server.ts` to build server-side callers. + +## Routers (`server/routers/`) + +### Root router (`server/routers/_app.ts`) + +```ts +export const appRouter = router({ + health: healthRouter, + auth: authRouter, + notifications: notificationsRouter, +}) + +export type AppRouter = typeof appRouter +``` + +`AppRouter` is the single type exported to the client. + +### `health` router (`server/routers/health.ts`) + +Uses `publicProcedure`. Fetches `GET ${BACKEND_URL}/health` (defaults to `http://localhost:8080`) and returns `{ status: string; database: string }`. Throws a plain `Error` if the backend responds with a non-2xx status. + +### `auth` router (`server/routers/auth.ts`) + +Uses `protectedProcedure`. Current stubs: +- `session` — query, returns `{ authenticated: true }`. +- `signOut` — mutation, returns `{ success: true }`. + +Both will be replaced when auth is implemented. + +### `notifications` router (`server/routers/notifications.ts`) + +Uses `protectedProcedure` with Zod input validation. Current stubs: +- `registerFcmToken` — mutation, input `{ token: z.string().min(1) }`, returns `{ registered: true, token }`. +- `list` — query, returns `[]`. + +## HTTP handler (`app/api/trpc/[trpc]/route.ts`) + +```ts +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: () => createTRPCContext({ req }), + }) + +export { handler as GET, handler as POST } +``` + +All tRPC requests (batch GET and mutation POST) hit `/api/trpc/[trpc]`. + +## Client setup (`lib/trpc/client.tsx`) + +```ts +export const trpc = createTRPCReact() +``` + +`trpc` is the typed client used in Client Components. + +### `getBaseUrl()` + +- In the browser: returns `''` (relative URL, same origin). +- On Vercel: returns `https://${process.env.VERCEL_URL}`. +- Elsewhere (local server-side): returns `'http://localhost:3000'`. + +### `TRPCProvider` + +```tsx +export function TRPCProvider({ children }: { children: React.ReactNode }) +``` + +Creates a `QueryClient` and a `trpc` HTTP batch link client, both memoized in `useState`. Wraps children with `trpc.Provider` and `QueryClientProvider`. This is a `'use client'` component. + +## Server-side caller (`lib/trpc/server.ts`) + +```ts +export const createServerCaller = cache(async () => { + const headerList = await headers() + const req = new Request('http://internal', { headers: headerList }) as NextRequest + const ctx = await createTRPCContext({ req }) + return createCaller(ctx) +}) +``` + +`createServerCaller` is wrapped in React's `cache()` so it is deduplicated per request. It forwards the incoming request headers (including `Cookie` and `Authorization`) to the context, which means `protectedProcedure` checks work for server-rendered pages. + +**Usage in a Server Component:** + +```tsx +import { createServerCaller } from '@/lib/trpc/server' + +export default async function HealthPage() { + const caller = await createServerCaller() + const health = await caller.health.query() + return

Backend status: {health.status}

+} +``` + +## Provider wiring (`app/providers.tsx` + `app/layout.tsx`) + +`app/providers.tsx` is a thin `'use client'` wrapper: + +```tsx +'use client' +import { TRPCProvider } from '@/lib/trpc/client' + +export function Providers({ children }: { children: React.ReactNode }) { + return {children} +} +``` + +`app/layout.tsx` wraps `{children}` with `` inside ``: + +```tsx + + {children} + +``` + +## Usage patterns + +### Server Component + +```tsx +// No 'use client' — runs on the server +import { createServerCaller } from '@/lib/trpc/server' + +export default async function StatusPage() { + const caller = await createServerCaller() + const health = await caller.health.query() + return

{health.status}

+} +``` + +### Client Component + +```tsx +'use client' +import { trpc } from '@/lib/trpc/client' + +export function HealthWidget() { + const { data, isLoading, isError } = trpc.health.query.useQuery() + + if (isLoading) return

Loading…

+ if (isError) return

Unavailable

+ return

Status: {data.status} / DB: {data.database}

+} +``` + +### Mutation in a Client Component + +```tsx +'use client' +import { trpc } from '@/lib/trpc/client' + +export function SignOutButton() { + const signOut = trpc.auth.signOut.useMutation() + return ( + + ) +} +``` + +## Adding a new router + +1. Create `server/routers/.ts` and export a `router({})` built from `publicProcedure` or `protectedProcedure`. +2. Import it in `server/routers/_app.ts` and add it to `appRouter`. +3. The new procedures are immediately available to `trpc..*` in Client Components and `caller..*` in Server Components. + +```ts +// server/routers/posts.ts +import { publicProcedure, router } from '../trpc' + +export const postsRouter = router({ + list: publicProcedure.query(async () => { + return [] + }), +}) +``` + +```ts +// server/routers/_app.ts +import { postsRouter } from './posts' + +export const appRouter = router({ + health: healthRouter, + auth: authRouter, + notifications: notificationsRouter, + posts: postsRouter, // add here +}) +``` + +## Testing + +Procedures are tested directly via `appRouter.createCaller(ctx)` — no HTTP server needed. + +Pattern from `server/routers/__tests__/health.test.ts` and `auth.test.ts`: + +```ts +import { appRouter } from '../_app' +import { createTRPCContext } from '../../trpc' + +function makeContext(reqHeaders: Record = {}) { + const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as NextRequest + return createTRPCContext({ req }) +} + +it('returns data', async () => { + const ctx = await makeContext() + const caller = appRouter.createCaller(ctx) + const result = await caller.health.query() + expect(result).toEqual({ status: 'ok', database: 'ok' }) +}) + +it('throws UNAUTHORIZED without session', async () => { + const ctx = await makeContext() + const caller = appRouter.createCaller(ctx) + await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) +}) + +it('allows Bearer token', async () => { + const ctx = await makeContext({ authorization: 'Bearer test-token' }) + const caller = appRouter.createCaller(ctx) + const result = await caller.auth.session() + expect(result).toEqual({ authenticated: true }) +}) +``` + +For procedures that call `fetch`, stub it with `vi.stubGlobal('fetch', mockFetch)` before the test suite. diff --git a/web/lib/trpc/client.tsx b/web/lib/trpc/client.tsx new file mode 100644 index 0000000..6e1c47f --- /dev/null +++ b/web/lib/trpc/client.tsx @@ -0,0 +1,34 @@ +'use client' + +import { createTRPCReact } from '@trpc/react-query' +import { useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { httpBatchLink } from '@trpc/client' +import type { AppRouter } from '@/server/routers/_app' + +export const trpc = createTRPCReact() + +function getBaseUrl() { + if (typeof window !== 'undefined') return '' + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + return 'http://localhost:3000' +} + +export function TRPCProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()) + const [trpcClient] = useState(() => + trpc.createClient({ + links: [ + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + ], + }) + ) + + return ( + + {children} + + ) +} diff --git a/web/lib/trpc/server.ts b/web/lib/trpc/server.ts new file mode 100644 index 0000000..6b440f5 --- /dev/null +++ b/web/lib/trpc/server.ts @@ -0,0 +1,17 @@ +import { createCallerFactory } from '@/server/trpc' +import { cache } from 'react' +import { headers } from 'next/headers' +import { createTRPCContext } from '@/server/trpc' +import { appRouter } from '@/server/routers/_app' + +const createCaller = createCallerFactory(appRouter) + +export const createServerCaller = cache(async () => { + const headerList = await headers() + // Build a minimal Request-like object for the context + const req = new Request('http://internal', { + headers: headerList, + }) as import('next/server').NextRequest + const ctx = await createTRPCContext({ req }) + return createCaller(ctx) +}) diff --git a/web/package.json b/web/package.json index 6c8e6ca..4269e72 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,10 @@ }, "dependencies": { "@sentry/nextjs": "^10.57.0", + "@tanstack/react-query": "^5.101.1", + "@trpc/client": "^11.18.0", + "@trpc/react-query": "^11.18.0", + "@trpc/server": "^11.18.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "firebase": "^12.14.0", @@ -25,7 +29,8 @@ "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e725226..443be0f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,18 @@ importers: '@sentry/nextjs': specifier: ^10.57.0 version: 10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2) + '@tanstack/react-query': + specifier: ^5.101.1 + version: 5.101.1(react@19.2.4) + '@trpc/client': + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/react-query': + specifier: ^11.18.0 + version: 11.18.0(@tanstack/react-query@5.101.1(react@19.2.4))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3) + '@trpc/server': + specifier: ^11.18.0 + version: 11.18.0(typescript@5.9.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -50,6 +62,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2098,6 +2113,14 @@ packages: '@tailwindcss/postcss@4.3.1': resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@tanstack/query-core@5.101.1': + resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==} + + '@tanstack/react-query@5.101.1': + resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2121,6 +2144,28 @@ packages: '@types/react-dom': optional: true + '@trpc/client@11.18.0': + resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} + hasBin: true + peerDependencies: + '@trpc/server': 11.18.0 + typescript: '>=5.7.2' + + '@trpc/react-query@11.18.0': + resolution: {integrity: sha512-C1+Wwm2pCeUJucI+bnFpxGYjNuvV+ko1BC1T9tUxBVdrhHRCdn9ubxdevdLSAa49XRJRJiZnSuzl3Ys/yvs1vg==} + peerDependencies: + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.18.0 + '@trpc/server': 11.18.0 + react: '>=18.2.0' + typescript: '>=5.7.2' + + '@trpc/server@11.18.0': + resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} + hasBin: true + peerDependencies: + typescript: '>=5.7.2' + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -7203,6 +7248,13 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.1 + '@tanstack/query-core@5.101.1': {} + + '@tanstack/react-query@5.101.1(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.101.1 + react: 19.2.4 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -7233,6 +7285,23 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@trpc/server': 11.18.0(typescript@5.9.3) + typescript: 5.9.3 + + '@trpc/react-query@11.18.0(@tanstack/react-query@5.101.1(react@19.2.4))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.18.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3)': + dependencies: + '@tanstack/react-query': 5.101.1(react@19.2.4) + '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/server': 11.18.0(typescript@5.9.3) + react: 19.2.4 + typescript: 5.9.3 + + '@trpc/server@11.18.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 diff --git a/web/server/routers/__tests__/auth.test.ts b/web/server/routers/__tests__/auth.test.ts new file mode 100644 index 0000000..a503790 --- /dev/null +++ b/web/server/routers/__tests__/auth.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { appRouter } from '../_app' +import { createTRPCContext } from '../../trpc' + +function makeContext(reqHeaders: Record = {}) { + const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as import('next/server').NextRequest + return createTRPCContext({ req }) +} + +describe('protectedProcedure', () => { + it('throws UNAUTHORIZED when no session present', async () => { + const ctx = await makeContext() + const caller = appRouter.createCaller(ctx) + await expect(caller.auth.session()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }) + }) + + it('allows access with Bearer token', async () => { + const ctx = await makeContext({ authorization: 'Bearer test-token-123' }) + const caller = appRouter.createCaller(ctx) + const result = await caller.auth.session() + expect(result).toEqual({ authenticated: true }) + }) + + it('allows access with __session cookie', async () => { + const ctx = await makeContext({ cookie: '__session=abc123' }) + const caller = appRouter.createCaller(ctx) + const result = await caller.auth.session() + expect(result).toEqual({ authenticated: true }) + }) +}) diff --git a/web/server/routers/__tests__/health.test.ts b/web/server/routers/__tests__/health.test.ts new file mode 100644 index 0000000..dad76c5 --- /dev/null +++ b/web/server/routers/__tests__/health.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { appRouter } from '../_app' +import { createTRPCContext } from '../../trpc' + +// mock fetch +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +function makeContext(reqHeaders: Record = {}) { + const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as import('next/server').NextRequest + return createTRPCContext({ req }) +} + +describe('health router', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('returns health data from backend', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ status: 'ok', database: 'ok' }), { status: 200 }) + ) + const ctx = await makeContext() + const caller = appRouter.createCaller(ctx) + const result = await caller.health.query() + expect(result).toEqual({ status: 'ok', database: 'ok' }) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/health')) + }) + + it('throws when backend is down', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 503 })) + const ctx = await makeContext() + const caller = appRouter.createCaller(ctx) + await expect(caller.health.query()).rejects.toThrow('Backend health check failed: 503') + }) +}) diff --git a/web/server/routers/_app.ts b/web/server/routers/_app.ts new file mode 100644 index 0000000..4a5ee86 --- /dev/null +++ b/web/server/routers/_app.ts @@ -0,0 +1,12 @@ +import { router } from '../trpc' +import { authRouter } from './auth' +import { healthRouter } from './health' +import { notificationsRouter } from './notifications' + +export const appRouter = router({ + health: healthRouter, + auth: authRouter, + notifications: notificationsRouter, +}) + +export type AppRouter = typeof appRouter diff --git a/web/server/routers/auth.ts b/web/server/routers/auth.ts new file mode 100644 index 0000000..5a0a517 --- /dev/null +++ b/web/server/routers/auth.ts @@ -0,0 +1,12 @@ +import { protectedProcedure, router } from '../trpc' + +export const authRouter = router({ + session: protectedProcedure.query(async () => { + // Stub — will be replaced when auth is implemented + return { authenticated: true } + }), + signOut: protectedProcedure.mutation(async () => { + // Stub — will be replaced when auth is implemented + return { success: true } + }), +}) diff --git a/web/server/routers/health.ts b/web/server/routers/health.ts new file mode 100644 index 0000000..af9abf4 --- /dev/null +++ b/web/server/routers/health.ts @@ -0,0 +1,11 @@ +import { publicProcedure, router } from '../trpc' + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080' + +export const healthRouter = router({ + query: publicProcedure.query(async () => { + const res = await fetch(`${BACKEND_URL}/health`) + if (!res.ok) throw new Error(`Backend health check failed: ${res.status}`) + return res.json() as Promise<{ status: string; database: string }> + }), +}) diff --git a/web/server/routers/notifications.ts b/web/server/routers/notifications.ts new file mode 100644 index 0000000..f17c89d --- /dev/null +++ b/web/server/routers/notifications.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { protectedProcedure, router } from '../trpc' + +export const notificationsRouter = router({ + registerFcmToken: protectedProcedure + .input(z.object({ token: z.string().min(1) })) + .mutation(async ({ input }) => { + // Stub — will be replaced when notifications are implemented + return { registered: true, token: input.token } + }), + list: protectedProcedure.query(async () => { + // Stub — will be replaced when notifications are implemented + return [] + }), +}) diff --git a/web/server/trpc.ts b/web/server/trpc.ts new file mode 100644 index 0000000..5bde102 --- /dev/null +++ b/web/server/trpc.ts @@ -0,0 +1,31 @@ +import { initTRPC, TRPCError } from '@trpc/server' +import { type NextRequest } from 'next/server' + +export interface TRPCContext { + req: NextRequest + // session will be added when auth is implemented +} + +export async function createTRPCContext({ req }: { req: NextRequest }): Promise { + return { req } +} + +const t = initTRPC.context().create() + +export const router = t.router +export const createCallerFactory = t.createCallerFactory +export const publicProcedure = t.procedure +export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { + // Read session token from Authorization header (Bearer) or __session cookie + const authHeader = ctx.req.headers.get('authorization') + const cookieHeader = ctx.req.headers.get('cookie') + + const hasBearer = authHeader?.startsWith('Bearer ') + const hasSessionCookie = cookieHeader?.includes('__session=') + + if (!hasBearer && !hasSessionCookie) { + throw new TRPCError({ code: 'UNAUTHORIZED' }) + } + + return next({ ctx }) +}) From 2fa6b59075af07cd4f63ce25cb7d5c4ea6d9f448 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Wed, 24 Jun 2026 03:49:09 +0300 Subject: [PATCH 2/2] fix(web): address CodeRabbit review findings on tRPC setup - protectedProcedure: reject empty Bearer tokens and match __session cookie by exact key (prevents substring-in-value spoofing) - health router: add 5 s AbortSignal timeout to backend fetch - health.test.ts: unstub global fetch in afterAll to prevent leakage; update fetch assertion to include options argument - auth.test.ts: add edge-case tests for empty Bearer, empty cookie value, and __session= appearing inside another cookie's value - Extract getBaseUrl to lib/trpc/utils.ts; add @vitest-environment node unit tests covering browser, Vercel, and localhost branches - Add lib/trpc/__tests__/server.test.ts: unit tests for createServerCaller with mocked next/headers and react.cache - backend: pin testcontainers postgres image to postgres:16 --- .../postgres/health_repository_test.go | 2 +- web/lib/trpc/__tests__/server.test.ts | 30 +++++++++++++++++++ web/lib/trpc/__tests__/utils.test.ts | 29 ++++++++++++++++++ web/lib/trpc/client.tsx | 7 +---- web/lib/trpc/utils.ts | 5 ++++ web/server/routers/__tests__/auth.test.ts | 18 +++++++++++ web/server/routers/__tests__/health.test.ts | 9 ++++-- web/server/routers/health.ts | 2 +- web/server/trpc.ts | 15 +++++++--- 9 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 web/lib/trpc/__tests__/server.test.ts create mode 100644 web/lib/trpc/__tests__/utils.test.ts create mode 100644 web/lib/trpc/utils.ts diff --git a/backend/internal/infrastructure/database/postgres/health_repository_test.go b/backend/internal/infrastructure/database/postgres/health_repository_test.go index 3d402d5..e92c5be 100644 --- a/backend/internal/infrastructure/database/postgres/health_repository_test.go +++ b/backend/internal/infrastructure/database/postgres/health_repository_test.go @@ -23,7 +23,7 @@ func mustStartPostgresContainer() (func(context.Context, ...testcontainers.Termi container, err := tcpostgres.Run( context.Background(), - "postgres:latest", + "postgres:16", tcpostgres.WithDatabase(dbName), tcpostgres.WithUsername(dbUser), tcpostgres.WithPassword(dbPwd), diff --git a/web/lib/trpc/__tests__/server.test.ts b/web/lib/trpc/__tests__/server.test.ts new file mode 100644 index 0000000..5f2ab79 --- /dev/null +++ b/web/lib/trpc/__tests__/server.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers({ 'x-test': 'true' })), +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, cache: (fn: unknown) => fn } +}) + +const { createServerCaller } = await import('../server') + +describe('createServerCaller', () => { + it('returns a caller with the expected router shape', async () => { + const caller = await createServerCaller() + expect(typeof caller.health.query).toBe('function') + expect(typeof caller.auth.session).toBe('function') + expect(typeof caller.auth.signOut).toBe('function') + expect(typeof caller.notifications.list).toBe('function') + }) + + it('propagates request headers into the tRPC context', async () => { + const { headers } = await import('next/headers') + const mockHeaders = new Headers({ authorization: 'Bearer test-token' }) + vi.mocked(headers).mockResolvedValueOnce(mockHeaders as Awaited>) + const caller = await createServerCaller() + expect(caller).toBeDefined() + }) +}) diff --git a/web/lib/trpc/__tests__/utils.test.ts b/web/lib/trpc/__tests__/utils.test.ts new file mode 100644 index 0000000..ac2e2de --- /dev/null +++ b/web/lib/trpc/__tests__/utils.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment node +import { describe, it, expect, afterEach } from 'vitest' +import { getBaseUrl } from '../utils' + +afterEach(() => { + delete process.env.VERCEL_URL +}) + +describe('getBaseUrl', () => { + it('returns empty string when window is defined (browser)', () => { + // In jsdom/browser environments window is defined; here we simulate it in node + const g = global as Record + g.window = {} + try { + expect(getBaseUrl()).toBe('') + } finally { + delete g.window + } + }) + + it('returns Vercel URL when VERCEL_URL env var is set', () => { + process.env.VERCEL_URL = 'my-app.vercel.app' + expect(getBaseUrl()).toBe('https://my-app.vercel.app') + }) + + it('falls back to localhost:3000', () => { + expect(getBaseUrl()).toBe('http://localhost:3000') + }) +}) diff --git a/web/lib/trpc/client.tsx b/web/lib/trpc/client.tsx index 6e1c47f..53d0750 100644 --- a/web/lib/trpc/client.tsx +++ b/web/lib/trpc/client.tsx @@ -5,15 +5,10 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { httpBatchLink } from '@trpc/client' import type { AppRouter } from '@/server/routers/_app' +import { getBaseUrl } from './utils' export const trpc = createTRPCReact() -function getBaseUrl() { - if (typeof window !== 'undefined') return '' - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` - return 'http://localhost:3000' -} - export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()) const [trpcClient] = useState(() => diff --git a/web/lib/trpc/utils.ts b/web/lib/trpc/utils.ts new file mode 100644 index 0000000..8472d78 --- /dev/null +++ b/web/lib/trpc/utils.ts @@ -0,0 +1,5 @@ +export function getBaseUrl() { + if (typeof window !== 'undefined') return '' + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + return 'http://localhost:3000' +} diff --git a/web/server/routers/__tests__/auth.test.ts b/web/server/routers/__tests__/auth.test.ts index a503790..8ee6702 100644 --- a/web/server/routers/__tests__/auth.test.ts +++ b/web/server/routers/__tests__/auth.test.ts @@ -29,4 +29,22 @@ describe('protectedProcedure', () => { const result = await caller.auth.session() expect(result).toEqual({ authenticated: true }) }) + + it('throws UNAUTHORIZED for empty Bearer token', async () => { + const ctx = await makeContext({ authorization: 'Bearer ' }) + const caller = appRouter.createCaller(ctx) + await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + }) + + it('throws UNAUTHORIZED for __session cookie with empty value', async () => { + const ctx = await makeContext({ cookie: '__session=' }) + const caller = appRouter.createCaller(ctx) + await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + }) + + it('throws UNAUTHORIZED when __session= appears only in another cookie value', async () => { + const ctx = await makeContext({ cookie: 'other=__session=abc' }) + const caller = appRouter.createCaller(ctx) + await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + }) }) diff --git a/web/server/routers/__tests__/health.test.ts b/web/server/routers/__tests__/health.test.ts index dad76c5..34b41a9 100644 --- a/web/server/routers/__tests__/health.test.ts +++ b/web/server/routers/__tests__/health.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import { appRouter } from '../_app' import { createTRPCContext } from '../../trpc' -// mock fetch const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) +afterAll(() => vi.unstubAllGlobals()) function makeContext(reqHeaders: Record = {}) { const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as import('next/server').NextRequest @@ -24,7 +24,10 @@ describe('health router', () => { const caller = appRouter.createCaller(ctx) const result = await caller.health.query() expect(result).toEqual({ status: 'ok', database: 'ok' }) - expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/health')) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/health'), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) }) it('throws when backend is down', async () => { diff --git a/web/server/routers/health.ts b/web/server/routers/health.ts index af9abf4..a2efc40 100644 --- a/web/server/routers/health.ts +++ b/web/server/routers/health.ts @@ -4,7 +4,7 @@ const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080' export const healthRouter = router({ query: publicProcedure.query(async () => { - const res = await fetch(`${BACKEND_URL}/health`) + const res = await fetch(`${BACKEND_URL}/health`, { signal: AbortSignal.timeout(5000) }) if (!res.ok) throw new Error(`Backend health check failed: ${res.status}`) return res.json() as Promise<{ status: string; database: string }> }), diff --git a/web/server/trpc.ts b/web/server/trpc.ts index 5bde102..1d224d7 100644 --- a/web/server/trpc.ts +++ b/web/server/trpc.ts @@ -16,14 +16,21 @@ export const router = t.router export const createCallerFactory = t.createCallerFactory export const publicProcedure = t.procedure export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { - // Read session token from Authorization header (Bearer) or __session cookie const authHeader = ctx.req.headers.get('authorization') const cookieHeader = ctx.req.headers.get('cookie') - const hasBearer = authHeader?.startsWith('Bearer ') - const hasSessionCookie = cookieHeader?.includes('__session=') + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null + const hasValidBearer = typeof bearerToken === 'string' && bearerToken.length > 0 - if (!hasBearer && !hasSessionCookie) { + const sessionValue = cookieHeader + ?.split(';') + .map(c => c.trim()) + .find(c => c.startsWith('__session=')) + ?.slice('__session='.length) + .trim() + const hasValidSessionCookie = typeof sessionValue === 'string' && sessionValue.length > 0 + + if (!hasValidBearer && !hasValidSessionCookie) { throw new TRPCError({ code: 'UNAUTHORIZED' }) }