merge: convert to bun workspaces monorepo

This commit is contained in:
2026-06-28 05:31:30 +08:00
351 changed files with 485 additions and 127 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeAll } from "vitest"
// Auth integration tests require a fully-functional PostgreSQL database with
// the application schema (set via DATABASE_URL). If the env var is missing or
// the DB is unreachable, every test is skipped rather than failing.
let dbUsable = false
let dbHelper: any
if (process.env.DATABASE_URL) {
try {
const { db } = await import("@/lib/db/index")
await db.execute("SELECT 1")
dbUsable = true
} catch {
dbUsable = false
}
}
describe(dbUsable ? "Better-Auth integration" : "Better-Auth integration (skipped: database not usable)", () => {
beforeAll(async () => {
if (!dbUsable) return
const { getTestHelpers } = await import("@/lib/auth/test")
dbHelper = await getTestHelpers()
})
it("should create a user", async () => {
if (!dbUsable || !dbHelper) return
const user = await dbHelper.createUser({ email: "test@example.com" })
expect(user.email).toBe("test@example.com")
})
it("should create a session for a user", async () => {
if (!dbUsable || !dbHelper) return
const user = await dbHelper.createUser({ email: "session-test@example.com" })
await dbHelper.saveUser(user)
const { session, headers } = await dbHelper.login({ userId: user.id })
expect(session.userId).toBe(user.id)
expect(headers.get("cookie")).toBeTruthy()
await dbHelper.deleteUser(user.id)
})
})
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest"
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "../domain-block"
describe("isDeckyVaultEmail", () => {
it("returns true for a @deckyvault.xyz email (lowercase)", () => {
expect(isDeckyVaultEmail("user@deckyvault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email (mixed case)", () => {
expect(isDeckyVaultEmail("User@DeckyVault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email with plus addressing", () => {
expect(isDeckyVaultEmail("user+tag@deckyvault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email with surrounding whitespace", () => {
expect(isDeckyVaultEmail(" admin@deckyvault.xyz ")).toBe(true)
})
it("returns false for a @gmail.com email", () => {
expect(isDeckyVaultEmail("user@gmail.com")).toBe(false)
})
it("returns false for a @example.com email", () => {
expect(isDeckyVaultEmail("user@example.com")).toBe(false)
})
it("returns false for a subdomain like @mail.deckyvault.xyz", () => {
expect(isDeckyVaultEmail("user@mail.deckyvault.xyz")).toBe(false)
})
it("returns false for an empty string", () => {
expect(isDeckyVaultEmail("")).toBe(false)
})
it("returns false for a string without @", () => {
expect(isDeckyVaultEmail("deckyvault.xyz")).toBe(false)
})
it("returns true for a .DECKYVAULT.XYZ email (uppercase domain)", () => {
expect(isDeckyVaultEmail("admin@DECKYVAULT.XYZ")).toBe(true)
})
})
describe("DOMAIN_BLOCK_ERROR", () => {
it("is a non-empty string", () => {
expect(DOMAIN_BLOCK_ERROR).toBeTruthy()
expect(typeof DOMAIN_BLOCK_ERROR).toBe("string")
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
// Mock the auth module — no real betterAuth initialisation should run.
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}))
// Use dynamic imports so vi.mock is guaranteed to be registered before
// any module evaluation in bun test's shared-module-cache multi-file mode.
let requireAuth: typeof import("../guard")["requireAuth"]
let requireRole: typeof import("../guard")["requireRole"]
let requireAdmin: typeof import("../guard")["requireAdmin"]
let requireContributorOrAdmin: typeof import("../guard")["requireContributorOrAdmin"]
let requireModeratorOrAdmin: typeof import("../guard")["requireModeratorOrAdmin"]
let getSession: ReturnType<typeof vi.fn>
beforeAll(async () => {
const guard = await import("../guard")
requireAuth = guard.requireAuth
requireRole = guard.requireRole
requireAdmin = guard.requireAdmin
requireContributorOrAdmin = guard.requireContributorOrAdmin
requireModeratorOrAdmin = guard.requireModeratorOrAdmin
const mocked = await import("@/lib/auth")
getSession = mocked.auth.api.getSession
})
function mockSession(overrides: Record<string, unknown> = {}) {
getSession.mockResolvedValue({
user: {
id: "user-1",
name: "Test User",
email: "test@example.com",
role: "user",
...overrides,
},
session: {
id: "session-1",
userId: "user-1",
token: "token-abc",
},
})
}
function mockNoSession() {
getSession.mockResolvedValue(null)
}
beforeEach(() => {
getSession.mockReset()
})
describe("requireAuth", () => {
it("returns ok with user and session when authenticated", async () => {
mockSession()
const result = await requireAuth(new Headers())
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.user.id).toBe("user-1")
expect(result.session.id).toBe("session-1")
}
})
it("returns error 401 when not authenticated", async () => {
mockNoSession()
const result = await requireAuth(new Headers())
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(401)
expect(result.error).toBe("Unauthorized")
}
})
})
describe("requireRole", () => {
it("allows user with matching role", async () => {
mockSession({ role: "admin" })
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(true)
})
it("denies user with non-matching role (403)", async () => {
mockSession({ role: "user" })
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(403)
expect(result.error).toBe("Forbidden")
}
})
it("returns 401 when not authenticated", async () => {
mockNoSession()
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(401)
}
})
it("defaults to 'user' role when role is null", async () => {
mockSession({ role: null })
const result = await requireRole(new Headers(), ["user"])
expect(result.ok).toBe(true)
})
})
describe("requireAdmin", () => {
it("allows admin user", async () => {
mockSession({ role: "admin" })
const result = await requireAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies non-admin user (403)", async () => {
mockSession({ role: "moderator" })
const result = await requireAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
describe("requireContributorOrAdmin", () => {
it("allows contributor", async () => {
mockSession({ role: "contributor" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("allows admin", async () => {
mockSession({ role: "admin" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies regular user", async () => {
mockSession({ role: "user" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
describe("requireModeratorOrAdmin", () => {
it("allows moderator", async () => {
mockSession({ role: "moderator" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("allows admin", async () => {
mockSession({ role: "admin" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies contributor", async () => {
mockSession({ role: "contributor" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest"
import { checkPasswordStrength } from "../password-strength"
describe("checkPasswordStrength", () => {
it("returns weak (score 0) for empty string", () => {
const result = checkPasswordStrength("")
expect(result.level).toBe("weak")
expect(result.score).toBe(0)
expect(result.feedback).toContain("Enter a password")
})
it("returns fair for 'password' (common pattern penalty)", () => {
const result = checkPasswordStrength("password")
expect(result.level).toBe("fair")
expect(result.feedback).toContain("Avoid common passwords")
})
it("returns fair for short password with only letters", () => {
const result = checkPasswordStrength("abc")
expect(result.level).toBe("fair")
})
it("returns good for 10-char lowercase-only password", () => {
const result = checkPasswordStrength("abcdefghij")
expect(result.level).toBe("good")
})
it("returns good for 10-char mixed case + numbers", () => {
const result = checkPasswordStrength("AbcDef1234")
expect(["good", "strong", "excellent"]).toContain(result.level)
})
it("returns strong for 14-char with all character types", () => {
const result = checkPasswordStrength("MyP@ssw0rd!2024")
expect(["strong", "excellent"]).toContain(result.level)
})
it("returns excellent for 18+ char complex password", () => {
const result = checkPasswordStrength("C0mpl3x!P@ssw0rd#2024")
expect(result.level).toBe("excellent")
})
it("flags repeated characters", () => {
const result = checkPasswordStrength("aaaBBB111@@@")
expect(result.feedback).toContain("Avoid repeated characters")
})
it("flags common patterns like 'letmein'", () => {
const result = checkPasswordStrength("letmein1234")
expect(result.feedback).toContain("Avoid common passwords")
})
it("gives positive feedback for excellent passwords", () => {
const result = checkPasswordStrength("Tr0ub4dor&3X7r3m3ly!")
expect(result.feedback).toContain("Great password!")
})
it("handles unicode characters", () => {
const result = checkPasswordStrength("パスワード1234Abc!")
expect(result.level).toBeDefined()
expect(result.score).toBeGreaterThan(0)
})
})
@@ -0,0 +1,236 @@
import { describe, it, expect } from "vitest"
import {
loginEmailSchema,
loginSchema,
signupSchema,
otpSchema,
forgotPasswordSchema,
resetPasswordSchema,
} from "../validation"
describe("loginEmailSchema", () => {
it("accepts a valid email", () => {
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(true)
})
it("rejects empty string", () => {
const result = loginEmailSchema.safeParse({ email: "" })
expect(result.success).toBe(false)
})
it("rejects invalid email format", () => {
const result = loginEmailSchema.safeParse({ email: "not-an-email" })
expect(result.success).toBe(false)
})
it("rejects missing email field", () => {
const result = loginEmailSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe("loginSchema", () => {
it("accepts valid email and password", () => {
const result = loginSchema.safeParse({
email: "user@example.com",
password: "password123",
})
expect(result.success).toBe(true)
})
it("rejects empty password", () => {
const result = loginSchema.safeParse({
email: "user@example.com",
password: "",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe("Password is required")
}
})
it("rejects missing password field", () => {
const result = loginSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(false)
})
})
describe("signupSchema", () => {
it("accepts valid name, email, and password (≥10 chars)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("rejects short password (<10 chars)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@example.com",
password: "short",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain("at least 10 characters")
}
})
it("rejects empty name", () => {
const result = signupSchema.safeParse({
name: "",
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("rejects name longer than 100 chars", () => {
const result = signupSchema.safeParse({
name: "a".repeat(101),
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
})
describe("otpSchema", () => {
it("accepts exactly 6 digits", () => {
const result = otpSchema.safeParse({ otp: "123456" })
expect(result.success).toBe(true)
})
it("rejects 5 digits", () => {
const result = otpSchema.safeParse({ otp: "12345" })
expect(result.success).toBe(false)
})
it("rejects 7 digits", () => {
const result = otpSchema.safeParse({ otp: "1234567" })
expect(result.success).toBe(false)
})
it("accepts any 6-char string (schema only validates length)", () => {
const result = otpSchema.safeParse({ otp: "abc123" })
expect(result.success).toBe(true)
})
})
describe("forgotPasswordSchema", () => {
it("accepts valid email", () => {
const result = forgotPasswordSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(true)
})
it("rejects invalid email", () => {
const result = forgotPasswordSchema.safeParse({ email: "bad" })
expect(result.success).toBe(false)
})
})
describe("resetPasswordSchema", () => {
it("accepts matching passwords with valid OTP", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("rejects mismatched passwords", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "different",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe("Passwords do not match")
}
})
it("rejects short new password", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "short",
confirmPassword: "short",
})
expect(result.success).toBe(false)
})
it("rejects empty confirm password", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "",
})
expect(result.success).toBe(false)
})
it("rejects short OTP", () => {
const result = resetPasswordSchema.safeParse({
otp: "12345",
newPassword: "abcdefghij",
confirmPassword: "abcdefghij",
})
expect(result.success).toBe(false)
})
})
describe("signupSchema — domain block", () => {
it("rejects @deckyvault.xyz email when NODE_ENV is not development", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@deckyvault.xyz",
password: "abcdefghij",
})
expect(result.success).toBe(false)
if (!result.success) {
const emailIssues = result.error.issues.filter(
(i) => i.path[0] === "email"
)
expect(emailIssues.length).toBeGreaterThan(0)
expect(emailIssues[0].message).toContain("deckyvault.xyz")
}
})
it("rejects @DECKYVAULT.XYZ email (uppercase) when not in dev", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "admin@DECKYVAULT.XYZ",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("rejects @deckyvault.xyz with plus addressing when not in dev", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user+spam@deckyvault.xyz",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("accepts @gmail.com email (non-deckyvault domain)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@gmail.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("accepts valid email from any non-deckyvault domain", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "hello@outlook.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { auth } from "@/lib/auth"
import { db } from "@/lib/db/index"
import { user } from "@/lib/db/schema/auth"
import { eq } from "drizzle-orm"
import type { Session } from "better-auth"
type User = typeof auth.$Infer.Session.user
type ApiKeyGuardResult =
| { ok: true; user: User; session: Session | null; keyId: string }
| { ok: false; error: string; status: number }
/**
* Attempts to authenticate a request using an API key from the x-api-key header.
* Verifies the key via Better Auth and looks up the user from the database.
*/
export async function authenticateWithApiKey(
requestHeaders: Headers,
): Promise<ApiKeyGuardResult> {
const apiKey = requestHeaders.get("x-api-key")
if (!apiKey) {
return { ok: false, error: "Missing x-api-key header", status: 401 }
}
try {
const result = await auth.api.verifyApiKey({
body: {
key: apiKey,
},
})
if (!result.valid || !result.key) {
const errorMessage = String(result.error?.message ?? "Invalid API key")
return { ok: false, error: errorMessage, status: 401 }
}
const userId = result.key.referenceId
const keyId = result.key.id
// Look up the user directly from the database
const [dbUser] = await db
.select()
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!dbUser) {
return { ok: false, error: "User not found for API key", status: 401 }
}
// Build a minimal user object matching Better Auth's Session.user type
const authedUser: User = {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
emailVerified: dbUser.emailVerified,
image: dbUser.image,
createdAt: dbUser.createdAt,
updatedAt: dbUser.updatedAt,
role: dbUser.role ?? "user",
banned: dbUser.banned ?? null,
banReason: dbUser.banReason ?? null,
banExpires: dbUser.banExpires ?? null,
}
return {
ok: true,
user: authedUser,
session: null,
keyId,
}
} catch (err) {
console.error("[api-key-guard] API key verification failed:", err)
return { ok: false, error: "API key verification failed", status: 500 }
}
}
/**
* Combined auth guard: first tries session auth (cookie), then falls back
* to API key auth (x-api-key header). Returns the authenticated user.
*/
export async function requireAuthWithApiKeyFallback(
requestHeaders: Headers,
): Promise<ApiKeyGuardResult> {
// Try session auth first
const session = await auth.api.getSession({ headers: requestHeaders })
if (session) {
return {
ok: true,
user: session.user,
session: session.session,
keyId: "",
}
}
// Fall back to API key
return authenticateWithApiKey(requestHeaders)
}
+18
View File
@@ -0,0 +1,18 @@
export const DOMAIN_BLOCK_ERROR =
"Signing up with a @deckyvault.xyz email is not allowed."
/**
* Returns true if the email address has a deckyvault.xyz domain.
* Handles case-insensitive comparison, whitespace trimming, and
* plus-notation aliases (user+tag@domain → domain).
*
* Only matches the exact domain "deckyvault.xyz" — NOT subdomains
* like "mail.deckyvault.xyz".
*/
export function isDeckyVaultEmail(email: string): boolean {
const trimmed = email.trim().toLowerCase()
const atIndex = trimmed.lastIndexOf("@")
if (atIndex === -1) return false
const domain = trimmed.slice(atIndex + 1)
return domain === "deckyvault.xyz"
}
+47
View File
@@ -0,0 +1,47 @@
export const OTP_EXPIRY_SECONDS = 300
type OTPParams = {
email: string
otp: string
type: "sign-in" | "email-verification" | "forget-password" | "change-email"
}
const subjects: Record<OTPParams["type"], string> = {
"sign-in": "Sign in to DeckyVault",
"email-verification": "Verify your DeckyVault email",
"forget-password": "Reset your DeckyVault password",
"change-email": "Change your DeckyVault email",
}
export async function sendOTP({ email, otp, type }: OTPParams) {
const subject = subjects[type]
// If RESEND_API_KEY is set, use Resend. Otherwise, log to console in dev.
if (process.env.RESEND_API_KEY) {
try {
const { Resend } = await import("resend")
const resend = new Resend(process.env.RESEND_API_KEY)
await resend.emails.send({
from: process.env.EMAIL_FROM ?? "DeckyVault <noreply@deckyvault.xyz>",
to: email,
subject,
html: `
<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
<h2 style="color: #eb3779;">${subject}</h2>
<p>Your verification code is:</p>
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #571b8b;">${otp}</p>
<p style="color: #666; font-size: 14px;">This code expires in ${OTP_EXPIRY_SECONDS / 60} minutes. If you didn't request this, ignore this email.</p>
</div>
`,
})
} catch (error) {
console.error("[EMAIL OTP] Failed to send via Resend:", error)
throw new Error("Failed to send verification email")
}
} else if (process.env.NODE_ENV === "development") {
console.log(`[EMAIL OTP] To: ${email} | Type: ${type} | OTP: ${otp}`)
} else {
throw new Error("RESEND_API_KEY is not configured")
}
}
+61
View File
@@ -0,0 +1,61 @@
import { auth } from "@/lib/auth"
import type { Session } from "better-auth"
/**
* Imperative permission guards for use inside route handlers or non-Elysia
* contexts. For blanket authentication on an Elysia route, prefer the `auth`
* macro defined in `app/api/[[...slugs]]/route.ts`.
*/
type User = typeof auth.$Infer.Session.user
type GuardResult =
| { ok: true; user: User; session: Session }
| { ok: false; error: string; status: number }
export async function requireAuth(headers: Headers): Promise<GuardResult> {
const session = await auth.api.getSession({ headers })
if (!session) {
return { ok: false, error: "Unauthorized", status: 401 }
}
return {
ok: true,
user: session.user,
session: session.session,
}
}
export async function requireRole(
headers: Headers,
roles: string[],
): Promise<GuardResult> {
const authResult = await requireAuth(headers)
if (!authResult.ok) return authResult
const userRole = authResult.user.role ?? "user"
if (!roles.includes(userRole)) {
return { ok: false, error: "Forbidden", status: 403 }
}
return authResult
}
export async function requireAdmin(headers: Headers): Promise<GuardResult> {
return requireRole(headers, ["admin"])
}
export async function requireContributorOrAdmin(
headers: Headers,
): Promise<GuardResult> {
return requireRole(headers, ["contributor", "admin"])
}
export async function requireModeratorOrAdmin(
headers: Headers,
): Promise<GuardResult> {
return requireRole(headers, ["moderator", "admin"])
}
+93
View File
@@ -0,0 +1,93 @@
export type StrengthLevel = "weak" | "fair" | "good" | "strong" | "excellent"
export interface PasswordStrength {
score: number
level: StrengthLevel
feedback: string[]
}
const COMMON_PATTERNS = [
"password",
"123456",
"12345678",
"qwerty",
"abc123",
"monkey",
"master",
"dragon",
"login",
"admin",
"letmein",
"welcome",
"shadow",
"sunshine",
"trustno1",
"iloveyou",
]
export function checkPasswordStrength(password: string): PasswordStrength {
if (!password) {
return { score: 0, level: "weak", feedback: ["Enter a password"] }
}
let score = 0
const feedback: string[] = []
// Length scoring
if (password.length >= 10) score += 20
else feedback.push("Use at least 10 characters")
if (password.length >= 14) score += 10
else if (password.length >= 10) feedback.push("Use 14+ characters for extra security")
if (password.length >= 18) score += 10
// Character variety
const hasUpper = /[A-Z]/.test(password)
const hasLower = /[a-z]/.test(password)
const hasNumber = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
if (hasUpper) score += 15
else feedback.push("Add an uppercase letter")
if (hasLower) score += 15
else feedback.push("Add a lowercase letter")
if (hasNumber) score += 15
else feedback.push("Add a number")
if (hasSpecial) score += 15
else feedback.push("Add a special character (!@#$%^&*)")
// Common pattern check
const lower = password.toLowerCase()
const isCommon = COMMON_PATTERNS.some((p) => lower.includes(p))
if (!isCommon) score += 10
else feedback.push("Avoid common passwords")
// Repeated characters
const hasRepeated = /(.)\1{2,}/.test(password)
if (!hasRepeated) score += 5
else feedback.push("Avoid repeated characters")
// Mixed positions (not all numbers at end, not all caps at start)
const endsWithNumbers = /[0-9]+$/.test(password) && !/[0-9]/.test(password.slice(0, -3))
const startsWithCaps = /^[A-Z]{3,}/.test(password) && !/[A-Z]/.test(password.slice(3))
if (!endsWithNumbers && !startsWithCaps) score += 5
// Determine level
let level: StrengthLevel
if (score <= 20) level = "weak"
else if (score <= 40) level = "fair"
else if (score <= 60) level = "good"
else if (score <= 80) level = "strong"
else level = "excellent"
// If all checks pass, clear feedback
if (feedback.length === 0) {
feedback.push("Great password!")
}
return { score, level, feedback }
}
+46
View File
@@ -0,0 +1,46 @@
import { createAccessControl } from "better-auth/plugins/access"
import {
defaultStatements,
adminAc,
} from "better-auth/plugins/admin/access"
const statement = {
...defaultStatements,
game: ["create", "update", "delete", "review"],
performance: ["submit", "verify", "delete"],
hardware: ["create", "update"],
profile: ["view", "edit"],
} as const
export const ac = createAccessControl(statement)
export const user = ac.newRole({
profile: ["view"],
performance: ["submit"],
})
export const contributor = ac.newRole({
...user.statements,
profile: ["view", "edit"],
performance: ["submit", "verify"],
game: ["create", "update"],
hardware: ["update"],
})
export const moderator = ac.newRole({
...contributor.statements,
performance: ["submit", "verify", "delete"],
game: ["create", "update", "review"],
})
export const admin = ac.newRole({
...adminAc.statements,
...moderator.statements,
game: ["create", "update", "delete", "review"],
performance: ["submit", "verify", "delete"],
hardware: ["create", "update"],
profile: ["view", "edit"],
})
export const ROLES = ["user", "contributor", "moderator", "admin"] as const
export type RoleName = (typeof ROLES)[number]
+21
View File
@@ -0,0 +1,21 @@
import { betterAuth } from "better-auth"
import { testUtils } from "better-auth/plugins"
import { drizzleAdapter } from "@better-auth/drizzle-adapter"
import { db } from "@/lib/db/index"
export const testAuth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
plugins: [
testUtils({ captureOTP: true }),
],
emailAndPassword: {
enabled: true,
},
})
export async function getTestHelpers() {
const ctx = await testAuth.$context
return ctx.test
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod"
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "./domain-block"
export const loginEmailSchema = z.object({
email: z.string().email("Please enter a valid email address"),
})
export const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
})
export const signupSchema = z
.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be 100 characters or less"),
email: z.string().email("Please enter a valid email address"),
password: z
.string()
.min(10, "Password must be at least 10 characters"),
})
.refine(
(data) =>
process.env.NODE_ENV === "development" ||
!isDeckyVaultEmail(data.email),
{
message: DOMAIN_BLOCK_ERROR,
path: ["email"],
},
)
export const otpSchema = z.object({
otp: z.string().length(6, "OTP must be exactly 6 digits"),
})
export const forgotPasswordSchema = z.object({
email: z.string().email("Please enter a valid email address"),
})
export const resetPasswordSchema = z
.object({
otp: z.string().length(6, "OTP must be exactly 6 digits"),
newPassword: z
.string()
.min(10, "Password must be at least 10 characters"),
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
export type LoginEmailInput = z.infer<typeof loginEmailSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type SignupInput = z.infer<typeof signupSchema>
export type OtpInput = z.infer<typeof otpSchema>
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>