From 0b76c7edb01161369888e13d5238250f42477544 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 10 Jun 2026 02:55:15 -0700 Subject: [PATCH] feat: add auth database migration --- .../0001_better_auth_account_center.sql | 94 ++++++++++++ package.json | 5 +- scripts/generate-better-auth-migration.mjs | 138 ++++++++++++++++++ tests/auth-worker.test.ts | 26 ++++ wrangler.jsonc | 3 +- 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 migrations/0001_better_auth_account_center.sql create mode 100644 scripts/generate-better-auth-migration.mjs diff --git a/migrations/0001_better_auth_account_center.sql b/migrations/0001_better_auth_account_center.sql new file mode 100644 index 0000000..1d0e6b2 --- /dev/null +++ b/migrations/0001_better_auth_account_center.sql @@ -0,0 +1,94 @@ +-- Generated from Better Auth schema. Do not edit by hand. + +-- Run `pnpm db:generate` after changing auth plugins that affect persistence. + +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS "user" ( + "id" text NOT NULL PRIMARY KEY, + "name" text NOT NULL, + "email" text NOT NULL UNIQUE, + "emailVerified" integer NOT NULL, + "image" text, + "createdAt" date NOT NULL, + "updatedAt" date NOT NULL, + "twoFactorEnabled" integer, + "lastLoginMethod" text, + "role" text, + "banned" integer, + "banReason" text, + "banExpires" date +); + +CREATE TABLE IF NOT EXISTS "session" ( + "id" text NOT NULL PRIMARY KEY, + "expiresAt" date NOT NULL, + "token" text NOT NULL UNIQUE, + "createdAt" date NOT NULL, + "updatedAt" date NOT NULL, + "ipAddress" text, + "userAgent" text, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade, + "impersonatedBy" text +); + +CREATE TABLE IF NOT EXISTS "account" ( + "id" text NOT NULL PRIMARY KEY, + "accountId" text NOT NULL, + "providerId" text NOT NULL, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade, + "accessToken" text, + "refreshToken" text, + "idToken" text, + "accessTokenExpiresAt" date, + "refreshTokenExpiresAt" date, + "scope" text, + "password" text, + "createdAt" date NOT NULL, + "updatedAt" date NOT NULL +); + +CREATE TABLE IF NOT EXISTS "verification" ( + "id" text NOT NULL PRIMARY KEY, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expiresAt" date NOT NULL, + "createdAt" date NOT NULL, + "updatedAt" date NOT NULL +); + +CREATE TABLE IF NOT EXISTS "passkey" ( + "id" text NOT NULL PRIMARY KEY, + "name" text, + "publicKey" text NOT NULL, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade, + "credentialID" text NOT NULL, + "counter" integer NOT NULL, + "deviceType" text NOT NULL, + "backedUp" integer NOT NULL, + "transports" text, + "createdAt" date, + "aaguid" text +); + +CREATE TABLE IF NOT EXISTS "twoFactor" ( + "id" text NOT NULL PRIMARY KEY, + "secret" text NOT NULL, + "backupCodes" text NOT NULL, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE cascade, + "verified" integer +); + +CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId"); + +CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId"); + +CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "verification" ("identifier"); + +CREATE INDEX IF NOT EXISTS "passkey_userId_idx" ON "passkey" ("userId"); + +CREATE INDEX IF NOT EXISTS "passkey_credentialID_idx" ON "passkey" ("credentialID"); + +CREATE INDEX IF NOT EXISTS "twoFactor_secret_idx" ON "twoFactor" ("secret"); + +CREATE INDEX IF NOT EXISTS "twoFactor_userId_idx" ON "twoFactor" ("userId"); diff --git a/package.json b/package.json index aa6a0ad..b68a039 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "db:apply:local": "wrangler d1 migrations apply cfw-auth --local", + "db:check": "node scripts/generate-better-auth-migration.mjs --check", + "db:generate": "node scripts/generate-better-auth-migration.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", - "ready": "pnpm typecheck && pnpm test" + "ready": "pnpm db:check && pnpm typecheck && pnpm test" }, "dependencies": { "@better-auth/passkey": "^1.6.16", diff --git a/scripts/generate-better-auth-migration.mjs b/scripts/generate-better-auth-migration.mjs new file mode 100644 index 0000000..0ffa1e6 --- /dev/null +++ b/scripts/generate-better-auth-migration.mjs @@ -0,0 +1,138 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { passkey } from "@better-auth/passkey"; +import { getSchema } from "better-auth/db"; +import { + admin, + emailOTP, + haveIBeenPwned, + lastLoginMethod, + multiSession, + openAPI, + twoFactor, +} from "better-auth/plugins"; + +const outFile = fileURLToPath( + new URL("../migrations/0001_better_auth_account_center.sql", import.meta.url), +); + +const schema = getSchema({ + emailAndPassword: { + enabled: true, + }, + plugins: [ + openAPI(), + haveIBeenPwned(), + emailOTP({ + sendVerificationOTP: async () => {}, + }), + twoFactor(), + multiSession({ maximumSessions: 10 }), + lastLoginMethod({ storeInDatabase: true }), + admin(), + passkey({ + rpID: "localhost", + rpName: "cfw-auth", + origin: "http://localhost:8788", + }), + ], +}); + +function quoteIdent(value) { + return `"${value.replaceAll('"', '""')}"`; +} + +function sqliteType(field) { + if (field.type === "boolean" || field.type === "number") { + return "integer"; + } + + if (field.type === "date") { + return "date"; + } + + return "text"; +} + +function columnSql(name, field) { + const parts = [quoteIdent(name), sqliteType(field)]; + + if (field.required !== false) { + parts.push("NOT NULL"); + } + + if (field.unique) { + parts.push("UNIQUE"); + } + + if (field.references) { + const onDelete = field.references.onDelete ?? "cascade"; + parts.push( + `REFERENCES ${quoteIdent(field.references.model)}(${quoteIdent(field.references.field)}) ON DELETE ${onDelete}`, + ); + } + + return parts.join(" "); +} + +function sortTables(entries) { + return entries.sort((left, right) => { + const leftOrder = Number.isFinite(left[1].order) ? left[1].order : Number.MAX_SAFE_INTEGER; + const rightOrder = Number.isFinite(right[1].order) ? right[1].order : Number.MAX_SAFE_INTEGER; + + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + return left[0].localeCompare(right[0]); + }); +} + +function buildSql() { + const chunks = [ + "-- Generated from Better Auth schema. Do not edit by hand.", + "-- Run `pnpm db:generate` after changing auth plugins that affect persistence.", + "PRAGMA foreign_keys = ON;", + ]; + const indexes = []; + + for (const [tableName, table] of sortTables(Object.entries(schema))) { + const columns = [`${quoteIdent("id")} text NOT NULL PRIMARY KEY`]; + + for (const [fieldName, field] of Object.entries(table.fields)) { + columns.push(columnSql(fieldName, field)); + + if (field.index) { + const suffix = field.unique ? "uidx" : "idx"; + const indexName = `${tableName}_${fieldName}_${suffix}`; + const indexSql = field.unique ? "CREATE UNIQUE INDEX" : "CREATE INDEX"; + indexes.push( + `${indexSql} IF NOT EXISTS ${quoteIdent(indexName)} ON ${quoteIdent(tableName)} (${quoteIdent(fieldName)});`, + ); + } + } + + chunks.push( + `CREATE TABLE IF NOT EXISTS ${quoteIdent(tableName)} (\n ${columns.join(",\n ")}\n);`, + ); + } + + chunks.push(...indexes); + + return `${chunks.join("\n\n")}\n`; +} + +const sql = buildSql(); +const checkOnly = process.argv.includes("--check"); + +if (checkOnly) { + const current = await readFile(outFile, "utf8"); + if (current !== sql) { + throw new Error(`${outFile} is out of date. Run pnpm db:generate.`); + } +} else { + await mkdir(dirname(outFile), { recursive: true }); + await writeFile(outFile, sql, "utf8"); + console.log(`Wrote ${outFile}`); +} diff --git a/tests/auth-worker.test.ts b/tests/auth-worker.test.ts index 6730e16..bad941f 100644 --- a/tests/auth-worker.test.ts +++ b/tests/auth-worker.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest"; import worker from "../src/index"; import type { Env } from "../src/env"; +interface OpenAPISchemaResponse { + openapi: string; + info: { + title: string; + }; + paths: Record; +} + const env: Env = { BETTER_AUTH_URL: "http://localhost:8788", TRUSTED_ORIGINS: "http://localhost:8787", @@ -24,6 +32,24 @@ describe("cfw-auth worker", () => { expect(response.headers.get("content-type") ?? "").toContain("text/html"); }); + it("exposes Better Auth OpenAPI schema", async () => { + const response = await worker.fetch( + new Request("http://auth.local/api/auth/open-api/generate-schema"), + env, + ); + const schema = (await response.json()) as OpenAPISchemaResponse; + + expect(response.status).toBe(200); + expect(response.headers.get("content-type") ?? "").toContain("application/json"); + expect(schema).toMatchObject({ + openapi: expect.any(String), + info: { + title: expect.any(String), + }, + }); + expect(Object.keys(schema.paths)).toEqual(expect.arrayContaining(["/sign-in/email"])); + }); + it("applies configured CORS origin for auth routes", async () => { const response = await worker.fetch( new Request("http://auth.local/api/auth/reference", { diff --git a/wrangler.jsonc b/wrangler.jsonc index bfb8c4d..41642c7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -11,7 +11,8 @@ "TRUSTED_ORIGINS": "http://localhost:8787", "MAIL_PROVIDER": "resend", "MAIL_FROM": "noreply@example.com", - "CAPTCHA_PROVIDER": "cloudflare-turnstile" + "CAPTCHA_PROVIDER": "cloudflare-turnstile", + "RESEND_API_KEY": "set-with-wrangler-secret-for-production" }, "d1_databases": [ {