> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API Route Patterns

> Implementing and organizing API routes in the Tuturuuu platform

The Tuturuuu platform uses Next.js App Router API routes with consistent patterns for versioning, authentication, error handling, and workspace isolation.

During the TanStack Start migration, new backend API ownership should move to
`apps/backend` instead of adding more long-lived `apps/web` route handlers. See
`platform/architecture/tanstack-rust-migration` for the route manifest, OpenAPI,
Docker, E2E, and benchmark gates.

## Route Organization

### Directory Structure

```
apps/web/src/app/api/
├── v1/                    # Versioned public API
│   ├── workspaces/
│   │   └── route.ts
│   └── users/
│       └── route.ts
├── ai/                    # AI-specific endpoints
│   ├── chat/
│   │   └── route.ts
│   └── generate/
│       └── route.ts
├── [wsId]/               # Workspace-scoped endpoints
│   ├── tasks/
│   │   └── route.ts
│   └── members/
│       └── route.ts
├── auth/                 # Authentication endpoints
│   ├── otp/
│   │   └── route.ts
│   └── mfa/
│       └── route.ts
└── workspaces/           # Workspace management
    └── route.ts
```

## Upstream Proxy Routes

When an `apps/web` route proxies requests to an upstream service and needs
low-level Undici transport controls such as a custom `dispatcher` or larger
`maxHeaderSize`, prefer `undici.request(...)` over the global server `fetch`.

On Next.js 16, the wrapped route-handler `fetch` can reject custom Undici
dispatchers with `UND_ERR_INVALID_ARG` / `invalid onRequestStart method`. Keep
custom transport configuration on the direct Undici request instead of passing
it through `fetch`.

## Authentication Wrappers

`apps/web` routes do not hand-roll `try/catch` + `supabase.auth.getUser()`
membership checks. Two wrappers in `apps/web/src/lib` centralize IP blocking,
pre-auth rate limiting, payload-size limits, suspension checks, and adaptive
abuse controls so handlers only contain business logic.

| Wrapper           | Module                 | Auth source                                                                         | Handler context                                                 |
| ----------------- | ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `withSessionAuth` | `@/lib/api-auth`       | Signed-in Supabase session (cookie/Bearer JWT, optional AI temp auth / app-session) | `{ user, supabase }`                                            |
| `withApiAuth`     | `@/lib/api-middleware` | Workspace API key (`Authorization: Bearer ttr_...`)                                 | `{ context, params }` where `context` is the `WorkspaceContext` |

Both wrappers receive route params as a **Promise** (Next.js App Router
behavior) and resolve them before invoking the handler. The session wrapper
exposes a `TypedSupabaseClient` already scoped to the authenticated user, so
RLS stays intact.

> The older `authorizeRequest`/`authorize` helpers in `@/lib/api-auth` are
> `@deprecated`. Prefer `withSessionAuth` for new routes.

## Versioned Public API

### Pattern: `/api/v1/*`

Use for product and public-facing APIs. For session-authenticated dashboard
surfaces, wrap the handler with `withSessionAuth`. The wrapper provides the
authenticated `user` and a request-scoped `supabase` client; the third handler
argument is the resolved route params.

```typescript theme={null}
// app/api/v1/workspaces/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import { NextResponse } from 'next/server';
import { z } from 'zod';

// GET /api/v1/workspaces
export const GET = withSessionAuth(async (_request, { user, supabase }) => {
  // Fetch the caller's workspaces (RLS-scoped via `supabase`)
  const { data: workspaces, error } = await supabase
    .from('workspace_members')
    .select(
      `
      ws_id,
      role,
      workspaces (
        id,
        name,
        logo_url
      )
    `
    )
    .eq('user_id', user.id)
    .eq('pending', false);

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json({
    data: workspaces?.map((w) => w.workspaces),
  });
});

// POST /api/v1/workspaces
const createWorkspaceSchema = z.object({
  name: z.string().min(1).max(100),
  logo_url: z.url().optional(),
});

export const POST = withSessionAuth(async (request, { user, supabase }) => {
  // Validate request body
  const body = await request.json();
  const parsed = createWorkspaceSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid input', details: parsed.error.issues },
      { status: 400 }
    );
  }

  // Create workspace
  const { data: workspace, error } = await supabase
    .from('workspaces')
    .insert({
      name: parsed.data.name,
      logo_url: parsed.data.logo_url,
    })
    .select()
    .single();

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  // Add creator as owner
  await supabase.from('workspace_members').insert({
    ws_id: workspace.id,
    user_id: user.id,
    role: 'owner',
  });

  return NextResponse.json({ data: workspace }, { status: 201 });
});
```

> When you do need a Supabase client outside a wrapper (Server Components,
> background jobs), remember the factory async-ness in
> `@tuturuuu/supabase/next/server`: `createClient()` and `createDynamicClient()`
> are async and must be awaited; `createAdminClient()` is synchronous (do not
> `await` it unless you pass it through another async helper).

## Workspace-Scoped API

### Pattern: `/api/v1/workspaces/[wsId]/*`

Use for workspace-scoped operations. Route params are a Promise; the wrapper
resolves them and passes them as the third handler argument. Authorize with the
workspace helpers from `@tuturuuu/utils/workspace-helper`:

* `normalizeWorkspaceId(wsId, supabase)` — resolves `personal`/handle aliases to
  a concrete UUID.
* `getPermissions({ wsId, user })` — returns a `PermissionsResult` with
  `containsPermission(permissionId)` / `withoutPermission(permissionId)`. It
  returns `null` when the caller is not a member, which doubles as the
  membership check.

```typescript theme={null}
// app/api/v1/workspaces/[wsId]/tasks/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import {
  getPermissions,
  normalizeWorkspaceId,
} from '@tuturuuu/utils/workspace-helper';
import { NextResponse } from 'next/server';
import { z } from 'zod';

type Params = { wsId: string };

// GET /api/v1/workspaces/[wsId]/tasks
export const GET = withSessionAuth<Params>(
  async (request, { user, supabase }, { wsId }) => {
    const workspaceId = await normalizeWorkspaceId(wsId, supabase);

    // getPermissions returns null for non-members → 403
    const permissions = await getPermissions({ wsId: workspaceId, user });
    if (!permissions) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    const searchParams = new URL(request.url).searchParams;
    const listId = searchParams.get('listId');
    const completed = searchParams.get('completed');

    let query = supabase
      .from('workspace_tasks')
      .select('*')
      .eq('ws_id', workspaceId);

    if (listId) query = query.eq('list_id', listId);
    if (completed !== null) query = query.eq('completed', completed === 'true');

    const { data: tasks, error } = await query;

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    return NextResponse.json({ data: tasks });
  }
);

// POST /api/v1/workspaces/[wsId]/tasks
const createTaskSchema = z.object({
  name: z.string().min(1).max(255),
  description: z.string().optional(),
  listId: z.string(),
  priority: z.number().int().min(0).max(5).optional(),
  dueDate: z.string().datetime().optional(),
});

export const POST = withSessionAuth<Params>(
  async (request, { user, supabase }, { wsId }) => {
    const workspaceId = await normalizeWorkspaceId(wsId, supabase);

    const permissions = await getPermissions({ wsId: workspaceId, user });
    if (!permissions?.containsPermission('manage_projects')) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }

    const body = await request.json();
    const parsed = createTaskSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: 'Invalid input', details: parsed.error.issues },
        { status: 400 }
      );
    }

    const { data: task, error } = await supabase
      .from('workspace_tasks')
      .insert({
        name: parsed.data.name,
        description: parsed.data.description,
        list_id: parsed.data.listId,
        priority: parsed.data.priority,
        end_date: parsed.data.dueDate,
        creator_id: user.id,
      })
      .select()
      .single();

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    return NextResponse.json({ data: task }, { status: 201 });
  }
);
```

> Real task-board routes (for example
> `apps/web/src/app/api/v1/workspaces/[wsId]/tasks/route.ts`) keep handler logic
> in `@tuturuuu/apis/tu-do/tasks/route` and only wire the wrapper in the route
> file. Extract shared handler logic the same way when a route grows past a
> simple CRUD shape. Use `containsPermission()` with a real `PermissionId` (such
> as `'manage_projects'`); there is no `manage_tasks` permission and no
> `@/lib/permissions` module.

## AI Endpoints

### Pattern: `/api/ai/*`

Use for AI-specific operations with model selection and token tracking.

The repo uses **AI SDK v6** (`ai` ^6.x, `@ai-sdk/google` ^3.x). On v6,
`streamText` results expose `result.toUIMessageStreamResponse()` (the v4/v5
`toDataStreamResponse()` no longer exists), and `onFinish` usage is reported as
`usage.inputTokens` / `usage.outputTokens` (not `promptTokens` /
`completionTokens`). Wrap the route with `withSessionAuth` and pass
`allowAiTempAuth` if the endpoint must accept short-lived AI temp tokens.

```typescript theme={null}
// app/api/ai/chat/route.ts
import { withSessionAuth } from '@/lib/api-auth';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { streamText } from 'ai';
import { z } from 'zod';

const chatRequestSchema = z.object({
  wsId: z.string(),
  messages: z.array(
    z.object({
      role: z.enum(['user', 'assistant', 'system']),
      content: z.string(),
    })
  ),
  model: z.string().optional(),
});

export const POST = withSessionAuth(async (request, { supabase }) => {
  // Validate input
  const body = await request.json();
  const parsed = chatRequestSchema.safeParse(body);

  if (!parsed.success) {
    return new Response('Invalid input', { status: 400 });
  }

  // Check AI feature flag
  const { data: secret } = await supabase
    .from('workspace_secrets')
    .select('value')
    .eq('ws_id', parsed.data.wsId)
    .eq('name', 'ENABLE_AI')
    .single();

  if (secret?.value !== 'true') {
    return new Response('AI not enabled for workspace', { status: 403 });
  }

  const google = createGoogleGenerativeAI({
    apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
  });

  const model = parsed.data.model || 'gemini-2.0-flash';

  const result = streamText({
    model: google(model),
    messages: parsed.data.messages,
    onFinish: async ({ usage }) => {
      // Track token usage (AI SDK v6 field names)
      await supabase.from('workspace_ai_executions').insert({
        ws_id: parsed.data.wsId,
        model,
        input_tokens: usage.inputTokens ?? 0,
        output_tokens: usage.outputTokens ?? 0,
      });
    },
  });

  return result.toUIMessageStreamResponse();
});
```

Token-usage persistence across the live AI routes (chat, task journal,
suggestions, quizzes) consistently reads `usage.inputTokens ?? 0` and
`usage.outputTokens ?? 0` — mirror that when adding new AI endpoints.

## Authentication Endpoints

### Pattern: `/api/auth/*`

Use edge runtime for auth endpoints.

```typescript theme={null}
// app/api/auth/otp/route.ts
import { createClient } from '@tuturuuu/supabase/next/server';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const otpRequestSchema = z.object({
  email: z.email(),
});

export async function POST(request: NextRequest) {
  try {
    const supabase = await createClient();

    const body = await request.json();
    const parsed = otpRequestSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: 'Invalid email' },
        { status: 400 }
      );
    }

    const { error } = await supabase.auth.signInWithOtp({
      email: parsed.data.email,
      options: {
        emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
      },
    });

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 400 });
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}
```

## Error Response Standards

### Standard Error Format

```typescript theme={null}
interface ErrorResponse {
  error: string;
  details?: any;
  code?: string;
}
```

### HTTP Status Codes

* `200` - Success
* `201` - Created
* `204` - No Content
* `400` - Bad Request (validation errors)
* `401` - Unauthorized (not authenticated)
* `403` - Forbidden (not authorized)
* `404` - Not Found
* `409` - Conflict
* `422` - Unprocessable Entity
* `429` - Too Many Requests
* `500` - Internal Server Error

### Error Handling Pattern

```typescript theme={null}
export async function POST(request: NextRequest) {
  try {
    // Implementation
  } catch (error) {
    console.error('API error:', error);

    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Validation failed', details: error.issues },
        { status: 400 }
      );
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}
```

## API-Key Authenticated Routes

### Pattern: `withApiAuth`

External SDK clients authenticate with a workspace API key
(`Authorization: Bearer ttr_...`) instead of a Supabase session. Wrap those
routes with `withApiAuth` from `@/lib/api-middleware`. It validates the key,
enforces IP blocks, applies pre-auth and adaptive rate limits, logs API key
usage, optionally checks workspace permissions, and passes the resolved
`WorkspaceContext` plus route params to the handler.

```typescript theme={null}
// app/api/v1/workspaces/[wsId]/storage/route.ts
import { withApiAuth } from '@/lib/api-middleware';
import { NextResponse } from 'next/server';

type Params = { wsId: string };

export const GET = withApiAuth<Params>(
  async (_request, { params, context }) => {
    // `context.wsId` is the validated workspace from the API key
    const { wsId } = context;
    // params.wsId is the resolved (awaited) route segment
    return NextResponse.json({ wsId, route: params.wsId });
  },
  {
    permissions: ['manage_drive'], // checked via the API key's permission set
    rateLimit: { windowMs: 60000, maxRequests: 100 },
  }
);
```

Key points that differ from `withSessionAuth`:

* Permissions are declared in the wrapper `options.permissions` (a
  `PermissionId[]`) and checked against the API key's granted scopes via
  `hasAnyPermission` / `hasAllPermissions`. Set `requireAll: true` to require
  every listed permission.
* The handler context is `{ context, params }` (not `{ user, supabase }`); the
  API-key path does not hand you a session-scoped Supabase client.
* Rate-limit defaults: GET/HEAD reads are open, mutations default to 100
  req/min, and workspace-specific overrides from `workspace_secrets` take
  precedence.

The helper module also exports `validateQueryParams(request, schema)` and
`validateRequestBody(request, schema, maxBytes)` for Zod-validated, byte-size-capped
input handling.

## CORS Configuration

```typescript theme={null}
// app/api/v1/workspaces/route.ts
export async function OPTIONS(request: NextRequest) {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}
```

## Rate Limiting

For signed-in product APIs, prefer `withSessionAuth(...)` over manual
`supabase.auth.getUser()` calls. The wrapper resolves the session through local
JWT claims first and only falls back to `getUser()` when claims are unavailable,
which avoids exhausting Supabase Auth limits on request-heavy dashboard surfaces
such as task boards. Default GET/HEAD requests are not rate-limited by the
session wrapper; default mutations use the shared mutation budget unless the
route provides an explicit `rateLimit` option.

The API proxy still protects request-heavy dashboard reads before route auth is
validated. High-fanout task-board reads use a dedicated `task-board-read` proxy
bucket so normal board loading, per-list pagination, and focused revalidation do
not exhaust the generic anonymous read budget. The defaults are 600 requests per
minute, 12000 per hour, and 80000 per day, and can be tuned with
`API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE`,
`API_PROXY_TASK_BOARD_READ_LIMIT_HOUR`, and
`API_PROXY_TASK_BOARD_READ_LIMIT_DAY`.

Proxy-side `429` responses include support diagnostics when available:
`X-RateLimit-Client-IP`, `X-RateLimit-User-Id`, and
`X-RateLimit-User-Email`, plus the selected policy/window headers emitted by the
proxy guard. Exact server-verified browser sessions ending in `@tuturuuu.com`
are allowed through proxy-side rate-limit blocks for debugging with
`X-RateLimit-Warning: staff-debug-bypass`,
`X-RateLimit-Debug-Bypass: tuturuuu-staff`, and
`X-RateLimit-Original-Status: 429`. This bypass only applies after the server
revalidates the Supabase session, only to proxy guard rate-limit blocks, and does
not bypass malformed auth, permissions, payload-size checks, route-handler
errors, or non-staff/anonymous requests.

Finance invoice creation support reads use a separate
`finance-invoice-create-read` proxy bucket for the shared-IP burst created by
the new invoice flow. It covers GET/HEAD reads for customer search, products,
wallets, transaction categories, promotions, invoice default settings, linked
products, user group data, user promotion data, invoice history, and
subscription context. Invoice creation mutations remain on the default mutation
policy. The defaults are 600 requests per minute, 6000 per hour, and 40000 per
day, and can be tuned with
`API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE`,
`API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_HOUR`, and
`API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_DAY`.

### Verified sessions and trusted read uplift

Proxy limits default to per-IP anonymous buckets because auth headers and
cookies are forgeable at the edge — their presence alone must never raise a
budget. To separate genuinely signed-in browser sessions and give legitimate
teams (often many people behind one office NAT/VPN IP) higher read throughput,
the proxy consults a server-written **trust cache** in Redis, keyed by the same
subject keys as the abuse-reputation system (`session:<hash>`,
`cidr:<network>`, `ip:<addr>`):

* A genuinely trusted **session** (its key is a hash of the real auth cookie, so
  it cannot be forged) gets its own per-session read and mutation buckets, so
  signed-in teammates behind one shared IP no longer collide on the pre-auth
  proxy budget.
* A trusted **location** (office `cidr`/`ip`, learned automatically from organic
  reputation or set explicitly via an `abuse_trust_overrides` `cidr` entry in the
  abuse-intelligence admin API) uplifts the shared per-IP read limit for the
  whole team.

Untrusted traffic keeps the legacy per-IP limits, preserving single-IP abuse
caps. The cache only ever uplifts read limits (never mutation limits, and never
lowers limits — restrictive decisions stay server-side) and fails open to
neutral when Redis is unavailable. It is populated by the session write-through
in `recordResponseAbuseSignal` and direct session-auth helpers, and reconciled
every 10 minutes by the `/api/cron/infrastructure/sync-trust-cache` cron (which
also propagates admin trusted-location overrides). Set
`API_PROXY_EDGE_TRUST_ENABLED=0` to disable the edge trust cache and fall back to
per-IP keying; tune the cache lifetime with `EDGE_TRUST_CACHE_TTL_SECONDS`.

```typescript theme={null}
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
});

export async function POST(request: NextRequest) {
  const identifier = request.headers.get('x-forwarded-for') || 'anonymous';
  const { success } = await ratelimit.limit(identifier);

  if (!success) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { status: 429 }
    );
  }

  // Continue with request
}
```

## Best Practices

### ✅ DO

1. **Wrap handlers with the auth wrapper**
   ```typescript theme={null}
   // Session routes: getUser/membership/rate limits handled for you
   export const POST = withSessionAuth(async (request, { user, supabase }) => {
     /* ... */
   });
   // API-key routes: key validation + permission scopes
   export const GET = withApiAuth(handler, { permissions: ['manage_drive'] });
   ```

2. **Always validate input**
   ```typescript theme={null}
   const parsed = schema.safeParse(body);
   if (!parsed.success) return error(400);
   ```

3. **Verify workspace permissions**
   ```typescript theme={null}
   const workspaceId = await normalizeWorkspaceId(wsId, supabase);
   const permissions = await getPermissions({ wsId: workspaceId, user });
   if (!permissions?.containsPermission('manage_projects')) return error(403);
   ```

### ❌ DON'T

1. **Don't expose sensitive errors**
   ```typescript theme={null}
   // ❌ Bad
   return NextResponse.json({ error: error.stack });
   ```

2. **Don't skip workspace isolation**
   ```typescript theme={null}
   // ❌ Bad: Can access any workspace
   .delete().eq('id', taskId)

   // ✅ Good: Workspace-scoped
   .delete().eq('id', taskId).eq('ws_id', wsId)
   ```

3. **Don't reach for the admin client to skip authorization**
   ```typescript theme={null}
   // ❌ Bad: bypasses RLS and the caller's permission set
   const sbAdmin = createAdminClient(); // sync — do not await
   await sbAdmin.from('workspace_tasks').delete().eq('id', taskId);

   // ✅ Good: use the session-scoped `supabase` from withSessionAuth so RLS
   //         and getPermissions() still gate the write
   await supabase
     .from('workspace_tasks')
     .delete()
     .eq('id', taskId)
     .eq('ws_id', workspaceId);
   ```
   Use the admin client only for trusted server-side work that has already
   performed its own authorization (e.g. inside `getPermissions`), and remember
   it is synchronous.

## Related Documentation

* [Authentication](/platform/architecture/authentication)
* [Authorization](/platform/architecture/authorization)
* [tRPC](/platform/architecture/trpc)
* [Data Fetching](/platform/architecture/data-fetching)
