feat: add auth database migration

This commit is contained in:
2026-06-10 02:55:15 -07:00
parent a429b05f63
commit 0b76c7edb0
5 changed files with 264 additions and 2 deletions

View File

@@ -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");

View File

@@ -6,9 +6,12 @@
"scripts": { "scripts": {
"dev": "wrangler dev", "dev": "wrangler dev",
"deploy": "wrangler deploy", "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", "test": "vitest run",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"ready": "pnpm typecheck && pnpm test" "ready": "pnpm db:check && pnpm typecheck && pnpm test"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.6.16", "@better-auth/passkey": "^1.6.16",

View File

@@ -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}`);
}

View File

@@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest";
import worker from "../src/index"; import worker from "../src/index";
import type { Env } from "../src/env"; import type { Env } from "../src/env";
interface OpenAPISchemaResponse {
openapi: string;
info: {
title: string;
};
paths: Record<string, unknown>;
}
const env: Env = { const env: Env = {
BETTER_AUTH_URL: "http://localhost:8788", BETTER_AUTH_URL: "http://localhost:8788",
TRUSTED_ORIGINS: "http://localhost:8787", TRUSTED_ORIGINS: "http://localhost:8787",
@@ -24,6 +32,24 @@ describe("cfw-auth worker", () => {
expect(response.headers.get("content-type") ?? "").toContain("text/html"); 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 () => { it("applies configured CORS origin for auth routes", async () => {
const response = await worker.fetch( const response = await worker.fetch(
new Request("http://auth.local/api/auth/reference", { new Request("http://auth.local/api/auth/reference", {

View File

@@ -11,7 +11,8 @@
"TRUSTED_ORIGINS": "http://localhost:8787", "TRUSTED_ORIGINS": "http://localhost:8787",
"MAIL_PROVIDER": "resend", "MAIL_PROVIDER": "resend",
"MAIL_FROM": "noreply@example.com", "MAIL_FROM": "noreply@example.com",
"CAPTCHA_PROVIDER": "cloudflare-turnstile" "CAPTCHA_PROVIDER": "cloudflare-turnstile",
"RESEND_API_KEY": "set-with-wrangler-secret-for-production"
}, },
"d1_databases": [ "d1_databases": [
{ {