feat: add auth database migration
This commit is contained in:
94
migrations/0001_better_auth_account_center.sql
Normal file
94
migrations/0001_better_auth_account_center.sql
Normal 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");
|
||||
@@ -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",
|
||||
|
||||
138
scripts/generate-better-auth-migration.mjs
Normal file
138
scripts/generate-better-auth-migration.mjs
Normal 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}`);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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", {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user