UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

539 lines (413 loc) 14 kB
## 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`.