Compare commits

...

2 Commits

Author SHA1 Message Date
0f912012d6 fix(backend): guard against missing BETTER_AUTH_SECRET
Co-authored-by: Ona <no-reply@ona.com>
2026-03-16 22:37:39 +00:00
36bdf7e1bb feat(backend): add admin plugin and create-admin script
Add Better Auth admin plugin for role-based user management.
Includes a CLI script to create admin accounts.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-16 02:00:53 +00:00
6 changed files with 162 additions and 81 deletions

View File

@@ -2,6 +2,7 @@
// Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts // Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
import { betterAuth } from "better-auth" import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle" import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { admin } from "better-auth/plugins"
import { SQL } from "bun" import { SQL } from "bun"
import { drizzle } from "drizzle-orm/bun-sql" import { drizzle } from "drizzle-orm/bun-sql"
@@ -13,6 +14,7 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
plugins: [admin()],
}) })
export default auth export default auth

View File

@@ -9,8 +9,10 @@
"test": "bun test src/", "test": "bun test src/",
"db:generate": "bunx drizzle-kit generate", "db:generate": "bunx drizzle-kit generate",
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y", "db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
"db:push": "bunx drizzle-kit push",
"db:migrate": "bunx drizzle-kit migrate", "db:migrate": "bunx drizzle-kit migrate",
"db:studio": "bunx drizzle-kit studio" "db:studio": "bunx drizzle-kit studio",
"create-admin": "bun run src/scripts/create-admin.ts"
}, },
"dependencies": { "dependencies": {
"@aelis/core": "workspace:*", "@aelis/core": "workspace:*",

View File

@@ -1,10 +1,16 @@
import { betterAuth } from "better-auth" import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle" import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { admin } from "better-auth/plugins"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import * as schema from "../db/schema.ts" import * as schema from "../db/schema.ts"
export function createAuth(db: Database) { export function createAuth(db: Database) {
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is not set")
}
return betterAuth({ return betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
@@ -13,6 +19,7 @@ export function createAuth(db: Database) {
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
plugins: [admin()],
}) })
} }

View File

@@ -57,9 +57,7 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
* Creates a function to get session from headers. Useful for WebSocket upgrade validation. * Creates a function to get session from headers. Useful for WebSocket upgrade validation.
*/ */
export function createGetSessionFromHeaders(auth: Auth) { export function createGetSessionFromHeaders(auth: Auth) {
return async ( return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
headers: Headers,
): Promise<{ user: AuthUser; session: AuthSession } | null> => {
const session = await auth.api.getSession({ headers }) const session = await auth.api.getSession({ headers })
return session return session
} }
@@ -86,6 +84,10 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
image: null, image: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
role: "admin",
banned: false,
banReason: null,
banExpires: null,
} }
const session: AuthSession = { const session: AuthSession = {

View File

@@ -1,91 +1,96 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm"
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date()) .$onUpdate(() => new Date())
.notNull(), .notNull(),
}); role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
})
export const session = pgTable( export const session = pgTable(
"session", "session",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date()) .$onUpdate(() => new Date())
.notNull(), .notNull(),
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
userAgent: text("user_agent"), userAgent: text("user_agent"),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}, impersonatedBy: text("impersonated_by"),
(table) => [index("session_userId_idx").on(table.userId)], },
); (table) => [index("session_userId_idx").on(table.userId)],
)
export const account = pgTable( export const account = pgTable(
"account", "account",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text("account_id").notNull(), accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(), providerId: text("provider_id").notNull(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"), accessToken: text("access_token"),
refreshToken: text("refresh_token"), refreshToken: text("refresh_token"),
idToken: text("id_token"), idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"), accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"), scope: text("scope"),
password: text("password"), password: text("password"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date()) .$onUpdate(() => new Date())
.notNull(), .notNull(),
}, },
(table) => [index("account_userId_idx").on(table.userId)], (table) => [index("account_userId_idx").on(table.userId)],
); )
export const verification = pgTable( export const verification = pgTable(
"verification", "verification",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
identifier: text("identifier").notNull(), identifier: text("identifier").notNull(),
value: text("value").notNull(), value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date()) .$onUpdate(() => new Date())
.notNull(), .notNull(),
}, },
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)],
); )
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
})); }))
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [session.userId], fields: [session.userId],
references: [user.id], references: [user.id],
}), }),
})); }))
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [account.userId], fields: [account.userId],
references: [user.id], references: [user.id],
}), }),
})); }))

View File

@@ -0,0 +1,63 @@
/**
* Creates an admin user account via Better Auth's server-side API.
*
* Usage:
* bun run src/scripts/create-admin.ts --name "Admin" --email admin@example.com --password secret123
*
* Requires DATABASE_URL and BETTER_AUTH_SECRET to be set (reads .env automatically).
*/
import { parseArgs } from "util"
import { createAuth } from "../auth/index.ts"
import { createDatabase } from "../db/index.ts"
function parseCliArgs(): { name: string; email: string; password: string } {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
name: { type: "string" },
email: { type: "string" },
password: { type: "string" },
},
strict: true,
})
if (!values.name || !values.email || !values.password) {
console.error(
"Usage: bun run src/scripts/create-admin.ts --name <name> --email <email> --password <password>",
)
process.exit(1)
}
return { name: values.name, email: values.email, password: values.password }
}
async function main() {
const { name, email, password } = parseCliArgs()
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error("DATABASE_URL is not set")
process.exit(1)
}
const { db, close } = createDatabase(databaseUrl)
try {
const auth = createAuth(db)
const result = await auth.api.createUser({
body: { name, email, password, role: "admin" },
})
console.log(`Admin account created: ${result.user.id} (${result.user.email})`)
} finally {
await close()
}
}
main().catch((err) => {
console.error("Failed to create admin account:", err)
process.exit(1)
})