## 6. Auth Core (Better Auth)

Feature gate: only apply this section if auth is enabled.

### 6.1 Install auth with CLI

If kitcn is not bootstrapped yet, start there first:

```bash
npx kitcn@latest init -t next --yes
```

Use `npx kitcn@latest init --yes` instead for in-place adoption of the
current supported app.

Then install auth:

```bash
bunx kitcn add auth --yes
```

Local Convex rule:

1. `add auth --yes` installs the auth scaffold and finishes the first local auth bootstrap in one pass.
2. `kitcn dev` is the long-running local runtime; later edits to `convex/.env` auto-sync while it is running.
3. `kitcn env push` stays for `--prod`, `--rotate`, or explicit repair against an already active deployment.

### 6.2 Auth config provider

**Create:** `convex/functions/auth.config.ts`

```ts
import { getAuthConfigProvider } from "kitcn/auth/config";
import type { AuthConfig } from "convex/server";
import { getEnv } from "../lib/get-env";

export default {
  providers: [
    getEnv().JWKS
      ? getAuthConfigProvider({ jwks: getEnv().JWKS })
      : getAuthConfigProvider(),
  ],
} satisfies AuthConfig;
```

Treat generated auth secrets as owned by the CLI flow. Do not manually set
`BETTER_AUTH_SECRET` in setup/simulation unless explicitly requested.
Malformed `JWKS` values can fail Convex module analysis during push/codegen.

### 6.3 Define auth contract

**Create:** `<functionsDir>/auth.ts`

`functionsDir` comes from `convex.json.functions` (default: `convex`).
Scaffolded kitcn apps use `convex/functions/auth.ts`.

```ts
import { convex } from "kitcn/auth";
import { getEnv } from "../lib/get-env";
import authConfig from "./auth.config";
import { defineAuth } from "./generated/auth";

export default defineAuth(() => ({
  emailAndPassword: {
    enabled: true,
  },
  baseURL: getEnv().SITE_URL,
  plugins: [
    convex({
      authConfig,
      jwks: getEnv().JWKS,
    }),
  ],
  session: {
    expiresIn: 60 * 60 * 24 * 30,
    updateAge: 60 * 60 * 24 * 15,
  },
  telemetry: { enabled: false },
  trustedOrigins: [getEnv().SITE_URL],
}));
```

Canonical rule:

1. `npx kitcn@latest init --yes`, `bunx kitcn dev`, and `bunx kitcn add auth --yes` all drive generation of `convex/functions/generated/` when they own the local Convex flow.
2. `auth.ts` default-exports `defineAuth(() => ({ ...options, triggers }))` imported from `./generated/auth`.
3. Import runtime auth contract (`getAuth`, `authClient`, CRUD/triggers, `auth`) from `<functionsDir>/generated/auth`.
4. If `auth.ts` is missing or incomplete, codegen still succeeds and generated runtime exports `authEnabled = false` with setup guidance at call time.

Do not manually create `authClient`, `createApi` exports, or static `auth` in `auth.ts`.

### 6.3.1 User session query module

Ordering note:

1. This module intentionally uses `publicQuery` + `getAuth(ctx)` so it works before Section 6.9 upgrades cRPC auth builders.

**Create:** `convex/functions/user.ts`

```ts
import { z } from "zod";
import { getHeaders } from "kitcn/auth";

import { getAuth } from "./generated/auth";
import { publicQuery } from "../lib/crpc";

export const getSessionUser = publicQuery
  .output(
    z.union([
      z.object({
        id: z.string(),
        image: z.string().nullish(),
        isAdmin: z.boolean(),
        name: z.string().optional(),
        plan: z.string().optional(),
      }),
      z.null(),
    ])
  )
  .query(async ({ ctx }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });
    const user = session?.user;
    if (!user) {
      return null;
    }

    return {
      id: user.id,
      image: user.image,
      isAdmin: user.isAdmin ?? false,
      name: user.name,
      plan: user.plan,
    };
  });

export const getIsAuthenticated = publicQuery
  .output(z.boolean())
  .query(async ({ ctx }) => !!(await ctx.auth.getUserIdentity()));
```

### 6.3.2 Shared auth type contract

**Create:** `convex/shared/auth-shared.ts`

```ts
import type { getAuth } from "../functions/generated/auth";
import type { Select } from "./api";

export type Auth = ReturnType<typeof getAuth>;

export type SessionUser = Select<"user"> & {
  isAdmin: boolean;
  session: Select<"session">;
  impersonatedBy?: string | null;
  plan?: "premium" | "team";
};
```

### 6.4 Define auth tables in schema

If you used the kitcn scaffold, install auth once with:

```bash
bunx kitcn add auth --yes
```

After changing plugins or auth fields in `<functionsDir>/auth.ts`, refresh only
the auth-owned schema blocks with:

```bash
bunx kitcn add auth --schema --yes
```

Use the raw Convex preset only when the app stays on the plain Convex auth
path:

```bash
bunx kitcn add auth --preset convex --yes
```

That raw Convex path refreshes `authSchema.ts` and `schema.ts` together. It
assumes the raw Convex app is already initialized and does not support
`--schema`.

If you used section 5.1's schema template, these tables already exist.
Otherwise add:

- `user`
- `session`
- `account`
- `verification`
- `jwks`

Keep all auth reads/writes on ORM table definitions in `convex/functions/schema.ts`.

### 6.5 Register auth HTTP routes

Use `kitcn/auth/http` for `authMiddleware` or `registerRoutes`.
It auto-installs the Convex-safe `MessageChannel` polyfill, so no manual `http-polyfills.ts` file is needed.
`registerRoutes` is lazy by default. If the auth config uses a custom base path,
pass that same `basePath` in the route options.

**Create:** `convex/functions/http.ts`

Bootstrap note:

1. `http.ts` is parsed during startup/codegen.
2. Keep imports static (no lazy imports in Convex code).
3. If `_generated/*` modules are missing, run `bunx kitcn dev` first, then continue.

cRPC + Hono route shape:

```ts
import { authMiddleware } from "kitcn/auth/http";
import { createHttpRouter } from "kitcn/server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { getEnv } from "../lib/get-env";

import { router } from "../lib/crpc";
import { getAuth } from "./generated/auth";

const app = new Hono();

app.use(
  "/api/*",
  cors({
    origin: getEnv().SITE_URL,
    allowHeaders: ["Content-Type", "Authorization", "Better-Auth-Cookie"],
    exposeHeaders: ["Set-Better-Auth-Cookie"],
    credentials: true,
  })
);

app.use(authMiddleware(getAuth));

export const httpRouter = router({
  // register routers here
});

export default createHttpRouter(app, httpRouter);
```

### 6.6 Sync env and JWKS

`convex/.env` comes from base setup. Keep `SITE_URL` and any provider
credentials current there. For the normal local path, `SITE_URL` should stay on
`http://localhost:3000`.

Typical local values:

```bash
SITE_URL=http://localhost:3000
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
```

### Convex lane

Local Convex:

```bash
bunx kitcn dev
```

`kitcn init --yes`, `kitcn dev`, and `kitcn add auth --yes`
already handle the first local auth bootstrap pass when they own the flow.
While `kitcn dev` is running, later edits to `convex/.env` auto-sync.

Repair / remote sync:

```bash
bunx kitcn env push
```

Use this to sync static `JWKS` onto the target deployment too.

```bash
bunx kitcn env push --prod
bunx kitcn env push --rotate
```

Use `--prod` for production and `--rotate` when you want fresh keys plus fresh
`JWKS`.

`kitcn env push` writes the target deployment env for you. No manual copy step.

### Concave lane

Concave has no `kitcn env` wrapper. Export a manual `JWKS=...` line from the
target backend, then set that env manually:

```bash
bunx kitcn --backend concave auth jwks --url http://localhost:3210
bunx kitcn --backend concave auth jwks --rotate --url http://localhost:3210
```

Use `--url`, `--port`, or `--component` to target the right Concave runtime.
See `/docs/cli/backend#env` and `/docs/cli/backend#auth` for the full command
surface.

`kitcn auth jwks` only prints `JWKS=...`. Save that value into env yourself.

### 6.7 Production bootstrap notes

#### Convex lane

First prod deploy requires JWKS initialization:

```bash
bunx convex deploy --prod
bunx kitcn env push --prod
```

#### Concave lane

Concave has no `kitcn env push --prod` flow. Export a static JWKS payload from
the deployed backend, then set the printed `JWKS=...` line manually:

```bash
bunx kitcn --backend concave auth jwks --url https://your-concave-backend.example.com
```

Again: printed payload only. You still need to set the env manually.

### 6.9 Upgrade `convex/lib/crpc.ts` to auth-aware builders (only after Section 11.2 passes)

After non-auth baseline is green, replace `convex/lib/crpc.ts` with this auth-aware variant:

```ts
import { getHeaders } from "kitcn/auth";
import { CRPCError } from "kitcn/server";

import { getAuth } from "../functions/generated/auth";
import { initCRPC } from "../functions/generated/server";

const c = initCRPC
  .meta<{
    auth?: "optional" | "required";
    role?: "admin";
    ratelimit?: string;
  }>()
  .create();

const roleMiddleware = c.middleware(({ meta, ctx, next }) => {
  if (meta.role !== "admin") return next({ ctx });

  const user = (ctx as { user?: { isAdmin?: boolean } }).user;
  if (!user?.isAdmin) {
    throw new CRPCError({
      code: "FORBIDDEN",
      message: "Admin access required",
    });
  }

  return next({ ctx });
});

function requireAuth<T>(user: T | null): T {
  if (!user) {
    throw new CRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
  }
  return user;
}

export const publicQuery = c.query.meta({ auth: "optional" });
export const publicAction = c.action;
export const publicMutation = c.mutation;

export const privateQuery = c.query.internal();
export const privateMutation = c.mutation.internal();
export const privateAction = c.action.internal();

export const optionalAuthQuery = c.query
  .meta({ auth: "optional" })
  .use(async ({ ctx, next }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });

    return next({
      ctx: {
        ...ctx,
        user: session?.user ?? null,
        userId: session?.user?.id ?? null,
      },
    });
  });

export const authQuery = c.query
  .meta({ auth: "required" })
  .use(async ({ ctx, next }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });
    const user = requireAuth(session?.user ?? null);
    return next({ ctx: { ...ctx, user, userId: user.id } });
  })
  .use(roleMiddleware);

export const optionalAuthMutation = c.mutation
  .meta({ auth: "optional" })
  .use(async ({ ctx, next }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });

    return next({
      ctx: {
        ...ctx,
        user: session?.user ?? null,
        userId: session?.user?.id ?? null,
      },
    });
  });

export const authMutation = c.mutation
  .meta({ auth: "required" })
  .use(async ({ ctx, next }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });
    const user = requireAuth(session?.user ?? null);
    return next({ ctx: { ...ctx, user, userId: user.id } });
  })
  .use(roleMiddleware);

export const authAction = c.action
  .meta({ auth: "required" })
  .use(async ({ ctx, next }) => {
    const auth = getAuth(ctx);
    const session = await auth.api.getSession({
      headers: await getHeaders(ctx),
    });
    const user = requireAuth(session?.user ?? null);
    return next({ ctx: { ...ctx, user, userId: user.id } });
  });

export const publicRoute = c.httpAction;
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) {
    throw new CRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
  }
  return next({
    ctx: {
      ...ctx,
      userId: identity.subject,
      user: {
        id: identity.subject,
        email: identity.email,
        name: identity.name,
      },
    },
  });
});
export const optionalAuthRoute = c.httpAction.use(async ({ ctx, next }) => {
  const identity = await ctx.auth.getUserIdentity();
  return next({
    ctx: {
      ...ctx,
      userId: identity ? identity.subject : null,
      user: identity
        ? {
            id: identity.subject,
            email: identity.email,
            name: identity.name,
          }
        : null,
    },
  });
});
export const router = c.router;
```

### 6.10 Auth sign-in gate (required before Section 7+ and all optional modules/plugins)

Do not continue until all checks below pass:

1. Start local runtime with `bunx kitcn dev`
2. `bun run typecheck || bunx tsc --noEmit`
3. `bun test`
4. `bun run build`
5. Headed browser auth verification:
   - Open `/auth`
   - Complete sign-in with configured provider/credentials
   - Confirm session is established (signed-in UI/state visible)
   - Execute one protected query or mutation and confirm it succeeds (no `UNAUTHORIZED`)
6. Signed-out enforcement check:
   - In a signed-out context, call one protected path and confirm `UNAUTHORIZED` is returned.

Stop/go rule:

1. If any sign-in gate check fails, fix auth wiring first.
2. Do not continue to Section 7, 8, 9, or 10 until this gate is green.

## 10. Plugin Setup Modules

Feature gate each plugin independently after auth core.

### 10.1 Admin plugin

Server:

```ts
import { admin } from "better-auth/plugins";

plugins: [
  admin({
    defaultRole: "user",
  }),
];
```

Client:

```ts
import { adminClient } from "better-auth/client/plugins";

plugins: [adminClient()];
```

Schema needs admin fields on `user` + `impersonatedBy` on `session`.

### 10.2 Organizations plugin

Server: add `organization({...})` plugin config.

Client: add `organizationClient({...})` plugin config.

Schema: add `organization`, `member`, `invitation` (+ optional `team`, `teamMember`), and session fields `activeOrganizationId`/`activeTeamId`.
