UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

273 lines (218 loc) • 13.3 kB
--- name: lunora-setup-auth description: Adds authentication to a Lunora app with the `auth` registry item (better-auth via `@lunora/auth`, users and sessions in D1). Covers email/password sign-up and sign-in, Clerk or Auth0 OAuth, magic link, email OTP, copy-in auth screens (`lunora add auth-ui`), wiring `/api/auth/*` and `resolveIdentity` into the Worker, and gating functions on `ctx.auth.userId`. Use when the user asks to "add auth", "add login/sign-in/sign-up", "protect this query", or "add Clerk/Auth0/magic link/OTP", wants to keep an external identity provider (Clerk, WorkOS, Auth0, Supabase, any OIDC JWT) as the session owner, runs `lunora add auth` or `lunora registry add auth`, edits `lunora/auth/index.ts`, or `ctx.auth.userId` is unexpectedly `null`. --- # Lunora Setup Auth The `auth` registry item is built on `@lunora/auth`, a thin wrapper over [better-auth](https://www.better-auth.com). Users and sessions are stored in D1. Lunora's `SessionDO` is a separate TTL'd token store that `@lunora/auth` never calls, so don't wire it for auth. If you need auth state in a Durable Object instead of D1, use `LunoraAuthDO` / `.auth({ namespace, internalSecret })`. If the project has no Lunora backend yet, start with `lunora-quickstart`. ## Already have an identity provider? Skip better-auth If the app's users sign in through Clerk, WorkOS AuthKit, Auth0, Supabase or any OIDC issuer, and that provider should keep owning the session, don't add the `auth` item. Verify the provider's JWT instead with `@lunora/jwt-auth`: ```bash lunora init --auth=clerk # new project: or workos | auth0 (wires everything below) lunora registry add auth-clerk-jwt # existing project: or auth-workos-jwt | auth-auth0-jwt ``` ```ts import { clerk } from "@lunora/jwt-auth"; // workos, auth0, supabase, jwtIdentityResolver import { memoizeIdentity } from "lunorash/runtime"; defineApp<Env>() .shard((env) => env.SHARD) .extend((env) => ({ resolveIdentity: memoizeIdentity(clerk({ publishableKey: env.CLERK_PUBLISHABLE_KEY ?? "", authorizedParties: env.CLERK_AUTHORIZED_PARTIES ?? "" })), })) .build(); ``` On the client, create the `LunoraClient` with `authTokenOnUpgrade: true` and, once signed in, call `setAuthTokenProvider(client, getToken, { subject: userId })` from `lunorash/client`. It refreshes before `exp` and on `onTokenExpired`; map the provider's "signed out" error (WorkOS `LoginRequiredError`, Auth0 `login_required`) to `null`. `authTokenOnUpgrade` is what authenticates live-query sockets: the browser offers the bearer as a `Sec-WebSocket-Protocol` entry. Without it, a WorkOS or Auth0 socket, or a cross-origin Clerk one, is anonymous. Org claims arrive as `orgId` with the org role (`orgRole` for Clerk, `role` / `orgRoles` for WorkOS); no preset sets the app-wide `roles` RLS reads, since any Clerk user can create an organization and be its admin. Check the org role together with `orgId` in a policy. `CLERK_AUTHORIZED_PARTIES` is required. Full guide: `/docs/concepts/external-auth`, with runnable apps in `examples/clerk-auth` and `examples/workos-auth`. The rest of this skill covers the better-auth path. ## Step 1: Add the item ```bash lunora add auth # asks for the provider and D1 database name lunora add auth --provider clerk --db my-app-db # non-interactive (auth | clerk | auth0) lunora registry add auth # low-level: writes placeholder names ``` Then run `pnpm install`. The item does the following: 1. Adds `@lunora/auth`, `@lunora/mail` and `@lunora/server` to `package.json`. 2. Copies `lunora/auth/index.ts` into the project. The project owns this file and can edit it. It exports `buildAuth(env)`, `getAuth(env)` (memoized per isolate) and `mountAuth(env, request)`, which routes `/api/auth/*` and returns `undefined` for any other path. The scaffold turns on email/password with `requireEmailVerification: true` and sends verification and reset mail through `@lunora/mail`. It also adds the `uiConfig()` plugin. 3. Adds a D1 `DB` binding to `wrangler.jsonc`. The `database_id` is a placeholder. 4. Writes `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and `MAIL_FROM` to `.dev.vars`. ## Step 2: Database and env vars | Var | Secret | Notes | | -------------------- | ------ | --------------------------------------------------------------------------------- | | `BETTER_AUTH_SECRET` | yes | At least 32 chars (`openssl rand -base64 32`). Production: `wrangler secret put`. | | `BETTER_AUTH_URL` | no | Public base URL. better-auth uses it for callbacks and cookies. | | `MAIL_FROM` | no | Sender for verification and reset mail. | ```bash wrangler d1 create my-app-db # paste the returned id into the DB binding lunora doctor # flags a placeholder database_id ``` Don't declare the better-auth tables (`user`, `session`, `account`, `verification`) in `lunora/schema.ts`. better-auth owns them in D1. In dev the schema is auto-applied on the first `/api/auth/*` request. For production, pre-apply it with `compileMigrationsSql(auth.options)` piped to `wrangler d1 execute <db> --remote --file -`. The item README has the script. Re-run that migration after adding a plugin that brings its own tables, such as `organization` or `twoFactor`. ## Step 3: Wire the Worker There are two parts. `/api/auth/*` has to reach better-auth. `resolveIdentity` has to turn the session cookie into `ctx.auth`. If `resolveIdentity` is missing, sign-in still works but `ctx.auth.userId` stays `null` in every function. **Through the generated `defineApp` builder.** This is the default for templates. `.auth()` does both parts: it builds the instance lazily, runs `ensureMigrated`, dispatches `/api/auth/*` and sets `resolveIdentity`. A Vite-first app (`"main": "virtual:lunora/worker"`) has no entry file, so chain the call in the `lunora.config.ts` `app` hook. A hand-written entry (`src/server.ts`) chains it on `defineApp()` directly: ```ts // lunora.config.ts import type { AppBuilder, LunoraConfig } from "./lunora/_generated/app"; // Move the object `buildAuth` passes to `createAuth` (minus `database`; the // builder supplies the D1 adapter) into an exported `authOptions(env)` in // lunora/auth/index.ts, and have `buildAuth` spread it too. import { authOptions } from "./lunora/auth/index.js"; interface Env { BETTER_AUTH_SECRET: string; BETTER_AUTH_URL: string; DB: D1Database; } export default { target: "cloudflare", app: (app: AppBuilder<Env>): AppBuilder<Env> => app.auth({ d1: (env) => env.DB, options: (env) => authOptions(env), }), } satisfies LunoraConfig<Env>; ``` Reuse the scaffold's options rather than retyping a subset: a hand-written `{ emailAndPassword: { enabled: true } }` drops `requireEmailVerification`, `sendResetPassword` and `sendVerificationEmail`, so users sign in unverified and no verification or reset mail goes out. `.auth()` appears on the builder only after codegen sees `@lunora/auth` installed, so run `lunora codegen` first. **Hand-rolled `createWorker` entry.** Call the scaffolded `mountAuth` first in `fetch`, then hand every other request to the Lunora worker, which resolves the identity: ```ts import type { AuthEnv } from "../../lunora/auth/index.js"; import { getAuth, mountAuth } from "../../lunora/auth/index.js"; const worker = createWorker({ resolveIdentity: async (req, env) => { const session = await getAuth(env as AuthEnv).api.getSession({ headers: req.headers }); return session?.user ? { userId: session.user.id } : null; }, // … }); export default { async fetch(request: Request, env: AuthEnv, ctx: ExecutionContext): Promise<Response> { const authResponse = await mountAuth(env, request); if (authResponse) return authResponse; return worker.fetch(request, env, ctx); // RPC, WebSocket, everything else }, }; ``` ## Step 4: Providers and add-ons (optional) Each of these items requires the base `auth` item and installs it if it is missing: ```bash lunora add auth-clerk # Clerk OIDC (genericOAuth): CLERK_CLIENT_ID / _SECRET / CLERK_ISSUER_URL lunora add auth-auth0 # Auth0 OIDC: AUTH0_CLIENT_ID / _SECRET / AUTH0_DOMAIN lunora add auth-magic-link # passwordless link, sent via @lunora/mail lunora add auth-otp # email one-time code, sent via @lunora/mail lunora add auth-emails # styled React templates for the auth mails (renderEmail) lunora add auth-ui # copy-in screens; framework auto-detected (no React Native port) ``` The provider items copy a file into `lunora/auth/` but don't register it anywhere. Add the plugin to the `plugins` array yourself, in `buildAuth` or in `.auth({ options })`. For example, `plugins: [uiConfig(), clerk(env)]` with `import { clerk } from "./clerk.js"`. The other exports are `auth0`, `magicLinkPlugin` and `emailOtpPlugin`. The OAuth callback URL is `<BETTER_AUTH_URL>/api/auth/oauth2/callback/<clerk|auth0>`. ## Step 5: Use the session ### In functions ```ts import { LunoraError } from "lunorash/server"; import { mutation, v } from "#lunora/_generated/server.js"; export const createDocument = mutation.input({ title: v.string() }).mutation(async ({ ctx, args: { title } }) => { if (!ctx.auth.userId) { throw new LunoraError("UNAUTHORIZED", "not signed in"); } return ctx.db.insert("documents", { ownerId: ctx.auth.userId, title, createdAt: Date.now() }); }); ``` `ctx.auth.getIdentity()` returns the full session claims, such as email and name. For org or role checks, compose `withAuthPlugins(auth)` and call the better-auth server API. ### In the client The default is a better-auth **cookie session**. `LunoraClient` needs no token, so don't call `setToken` / `setAuthToken` for it. Sign in with the better-auth client. Build that client with `createLunoraAuthClient`, which installs `lunoraSessionSync()`. That plugin tells every open `LunoraClient`, including ones in other tabs, to re-resolve the session after sign-in or sign-out. Without it, live queries keep serving the previous user's rows. If you call `createAuthClient` yourself, add `lunoraSessionSync()` to its plugins. ```tsx import { createLunoraAuthClient } from "@lunora/auth/plugins/client"; import { Authenticated, AuthLoading, Unauthenticated, useAuth } from "@lunora/react"; import { createAuthClient } from "better-auth/react"; export const authClient = createLunoraAuthClient(createAuthClient); function Account({ email, password }: { email: string; password: string }) { const { user } = useAuth(); // { setToken, status, token, user } return ( <> <AuthLoading>…</AuthLoading> <Authenticated> <span>Signed in as {user?.email}</span> <button type="button" onClick={() => authClient.signOut()}> Sign out </button> </Authenticated> <Unauthenticated> <button type="button" onClick={() => authClient.signIn.email({ email, password })}> Sign in </button> </Unauthenticated> </> ); } ``` A client plugin toggle such as `{ plugins: { twoFactor: true } }` only adds the client half. Register the matching server plugin (`twoFactor()` from `@lunora/auth/plugins`) in `buildAuth`'s `plugins` too, or its endpoints don't exist. `setToken` is only for bearer-token setups, such as React Native or a JWT from an external IdP. Branch on `status`, not on `user === null`. `status` takes one of four values: `"unauthenticated"`, `"loading"`, `"authenticated"` or `"unreachable"`. The gates in `@lunora/vue`, `@lunora/solid`, `@lunora/svelte` and `@lunora/angular` use the same four. `"unreachable"` means a credential is held but the session endpoint failed, for example on an offline reload. The gates treat it as authenticated, yet `user` can still be `null` at that point. A UI that reads that `null` as "signed out" shows a signed-in user the signed-out screen. The full contract is documented on `AuthStatus` in `@lunora/client/auth`. ## Pitfalls - **Sign-in rejected right after sign-up.** The scaffold sets `requireEmailVerification: true`, so the user has to open the verification link first. In dev, that link is in the Studio Mail tab. - **Mail in production.** Dev captures mail into the Studio. In production with no `MAIL_FROM`, the scaffold throws rather than logging the link. Auth links are bearer credentials, so this is intentional. Production needs `lunora add email` (the `SEND_EMAIL` binding) or `RESEND_API_KEY`, plus a verified sender domain. See `lunora-setup-mail`. - **Unauthenticated storage or other items.** Items that call `requireOwner` throw `UNAUTHORIZED` until `resolveIdentity` is wired (Step 3). ## Verify 1. Run `lunora doctor`. It should report no placeholder `database_id`. 2. Run `lunora dev`. Sign up, open the verification link from the Studio Mail tab, then sign in. 3. Call a query that returns `ctx.auth.userId`. It should return the user's id, not `null`.