refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user