import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { accountDb } from "~/server/scoped-db"; import { forgetLocator } from "~/server/scoped-db/locators"; import { revokeAccessTokensWhere } from "~/server/access-token-store"; import { createSession, listSessions, loadUserById, revokeSession } from "~/server/auth"; import { enforceRateLimit, limitAccountWrite } from "~/server/limits"; import { conflict, notFoundError } from "~/server/errors"; import { confirmPassword, notifySecurityEvent, revokeAllSessions } from "~/server/account-security"; import { disableTotp, enableTotp, regenerateRecoveryCodes, remainingRecoveryCodes, requiresSecondFactor, startTotpSetup, } from "~/server/two-factor"; import { passkeyCreationOptions, registerPasskey } from "~/server/passkeys"; import { email } from "~/lib/validation"; import { devMailPreviewUrl } from "~/server/notify/dev-mail"; import { addAccountEmail, listAccountEmails, removeAccountEmail, resendAccountEmailVerification, setPrimaryAccountEmail, } from "~/server/user-emails"; const encoded = z.string().min(1).max(16384); const sudo = z.object({ password: z.string().max(200) }); export const getSecuritySettings = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); const user = await loadUserById(viewer.id); if (!user) throw notFoundError("Account not found"); const passkeys = await accountDb(viewer.id).all<{ id: string; name: string; last_used_at: number | null; created_at: number; }>("SELECT id, name, last_used_at, created_at FROM passkeys WHERE user_id = ? ORDER BY created_at", viewer.id); return { emails: await listAccountEmails(viewer.id), avatarUrl: user.avatar_key ? `/avatars/${user.avatar_key}` : null, hasPassword: user.password_hash !== null, totpEnabled: user.totp_enabled_at !== null, recoveryCodesLeft: await remainingRecoveryCodes(viewer.id), passkeys: passkeys.map((key) => ({ id: key.id, name: key.name, lastUsedAt: key.last_used_at, createdAt: key.created_at, })), sessions: await listSessions(viewer.id), }; }); export const beginTotpSetup = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(sudo) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await confirmPassword(viewer.id, data.password); return startTotpSetup(viewer.id, viewer.name); }); export const confirmTotpSetup = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ code: z.string().trim().min(6).max(10) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await enforceRateLimit("AUTH_LIMITER", `totp-setup:${viewer.id}`); const codes = await enableTotp(viewer.id, data.code); await notifySecurityEvent(viewer.id, "two-factor authentication was enabled"); return { recoveryCodes: codes }; }); export const disableTwoFactor = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(sudo) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await confirmPassword(viewer.id, data.password); await disableTotp(viewer.id); await notifySecurityEvent(viewer.id, "two-factor authentication was disabled"); return { ok: true }; }); export const newRecoveryCodes = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(sudo) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await confirmPassword(viewer.id, data.password); if (!(await requiresSecondFactor(viewer.id))) throw conflict("Enable two-factor authentication first"); const recoveryCodes = await regenerateRecoveryCodes(viewer.id); await notifySecurityEvent(viewer.id, "your two-factor recovery codes were regenerated"); return { recoveryCodes }; }); export const beginPasskeyRegistration = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(sudo) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await enforceRateLimit("AUTH_LIMITER", `passkey-register:${viewer.id}`); await confirmPassword(viewer.id, data.password); return passkeyCreationOptions(viewer); }); export const finishPasskeyRegistration = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ name: z.string().trim().min(1).max(64), challenge: z.string().min(1).max(128), clientDataJSON: encoded, attestationObject: encoded, transports: z.array(z.string().max(32)).max(8), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await registerPasskey(viewer, data.name, data); await notifySecurityEvent(viewer.id, `the passkey "${data.name}" was added`); const needsCodes = (await remainingRecoveryCodes(viewer.id)) === 0; return { recoveryCodes: needsCodes ? await regenerateRecoveryCodes(viewer.id) : null, }; }); export const deletePasskey = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ id: z.string().min(1).max(1400), password: z.string().max(200), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await confirmPassword(viewer.id, data.password); const db = accountDb(viewer.id); const result = await db.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", data.id, viewer.id); if (result.meta.changes === 0) throw notFoundError("Passkey not found"); await forgetLocator("passkey", data.id); if (!(await requiresSecondFactor(viewer.id))) await db.run("DELETE FROM recovery_codes WHERE user_id = ?", viewer.id); await notifySecurityEvent(viewer.id, "a passkey was removed"); return { ok: true }; }); export const resendVerificationEmail = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ email })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await enforceRateLimit("AUTH_LIMITER", `verify-send:${viewer.id}`); await resendAccountEmailVerification(viewer.id, data.email); return { ok: true, devMailUrl: devMailPreviewUrl(data.email) }; }); /** New addresses stay pending until the link sent to them is confirmed. */ export const addEmail = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ email, password: z.string().max(200) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await confirmPassword(viewer.id, data.password); await enforceRateLimit("AUTH_LIMITER", `verify-send:${viewer.id}`); await addAccountEmail(viewer.id, data.email); return { ok: true, devMailUrl: devMailPreviewUrl(data.email) }; }); export const setPrimaryEmail = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ email, password: z.string().max(200) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await confirmPassword(viewer.id, data.password); await limitAccountWrite(viewer); await setPrimaryAccountEmail(viewer.id, data.email); return { ok: true }; }); /** Removing an address is sensitive: a verified address can receive password resets (AUTH-P8). */ export const removeEmail = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ email, password: z.string().max(200) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await confirmPassword(viewer.id, data.password); await limitAccountWrite(viewer); await removeAccountEmail(viewer.id, data.email); return { ok: true }; }); /** `revokeTokens` also revokes every personal access and MCP/OAuth token, with everything they minted (AUTH-P13). */ export const signOutEverywhere = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ revokeTokens: z.boolean().default(false) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await revokeAllSessions(viewer.id); if (data.revokeTokens) { await revokeAccessTokensWhere(viewer.id, "1 = 1"); await notifySecurityEvent(viewer.id, "you signed out everywhere and revoked all of your access tokens"); } await createSession(viewer.id); return { ok: true }; }); /** Signs out a single device from the sessions list (AUTH-A12). */ export const revokeOtherSession = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ id: z.string().min(1).max(64) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); if (!(await revokeSession(viewer.id, data.id))) throw notFoundError("Session not found"); return { ok: true }; });