import Fastify, { type FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import fastifyCookie from "@fastify/cookie"; import fastifyJwt from "@fastify/jwt"; import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto"; import nodemailer from "nodemailer"; import { env } from "./env.js"; import { PrismaClient } from "@prisma/client"; import { z } from "zod"; import { getUserMidnightFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; import healthRoutes from "./routes/health.js"; import sessionRoutes from "./routes/session.js"; import userRoutes from "./routes/user.js"; import authAccountRoutes from "./routes/auth-account.js"; import variableCategoriesRoutes from "./routes/variable-categories.js"; import transactionsRoutes from "./routes/transactions.js"; import fixedPlansRoutes from "./routes/fixed-plans.js"; import incomeRoutes from "./routes/income.js"; import paydayRoutes from "./routes/payday.js"; import budgetRoutes from "./routes/budget.js"; import dashboardRoutes from "./routes/dashboard.js"; import siteAccessRoutes from "./routes/site-access.js"; import adminRoutes from "./routes/admin.js"; export type AppConfig = typeof env; const openPaths = new Set([ "/health", "/site-access/status", "/site-access/unlock", "/site-access/lock", "/auth/login", "/auth/register", "/auth/verify", "/auth/verify/resend", "/auth/forgot-password/request", "/auth/forgot-password/confirm", ]); const mutationRateLimit = { config: { rateLimit: { max: 60, timeWindow: 60_000, }, }, }; const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: 60_000, }, }, }; const codeVerificationRateLimit = { config: { rateLimit: { max: 8, timeWindow: 60_000, }, }, }; const codeIssueRateLimit = { config: { rateLimit: { max: 5, timeWindow: 60_000, }, }, }; const passwordResetRequestRateLimit = (max: number) => ({ config: { rateLimit: { max, timeWindow: 60_000, keyGenerator: (req: any) => { const rawEmail = req?.body && typeof req.body === "object" ? (req.body as Record)?.email : ""; const email = typeof rawEmail === "string" && rawEmail.trim().length > 0 ? rawEmail.trim().toLowerCase() : "unknown"; return `${req.ip}|${createHash("sha256").update(email).digest("hex").slice(0, 16)}`; }, }, }, }); const passwordResetConfirmRateLimit = (max: number) => ({ config: { rateLimit: { max, timeWindow: 60_000, keyGenerator: (req: any) => { const rawUid = req?.body && typeof req.body === "object" ? (req.body as Record)?.uid : ""; const uid = typeof rawUid === "string" && rawUid.trim().length > 0 ? rawUid.trim() : "unknown"; return `${req.ip}|${createHash("sha256").update(uid).digest("hex").slice(0, 16)}`; }, }, }, }); const pathOf = (url: string) => (url.split("?")[0] || "/"); const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase(); const isInternalClientIp = (ip: string) => { const normalized = normalizeClientIp(ip); if (normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost") { return true; } if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) { return true; } const parts = normalized.split("."); if (parts.length === 4 && parts[0] === "172") { const second = Number(parts[1]); if (Number.isFinite(second) && second >= 16 && second <= 31) { return true; } } return false; }; const CSRF_COOKIE = "csrf"; const CSRF_HEADER = "x-csrf-token"; const SITE_ACCESS_COOKIE = "skymoney_site_access"; const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60; declare module "fastify" { interface FastifyInstance { prisma: PrismaClient; ensureUser(userId: string): Promise; } interface FastifyRequest { userId: string; } } export async function buildApp(overrides: Partial = {}): Promise { const config = { ...env, ...overrides } as AppConfig; const isProd = config.NODE_ENV === "production"; const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE; const cookieDomain = config.COOKIE_DOMAIN || undefined; const siteAccessExpectedToken = config.BREAK_GLASS_VERIFY_CODE && config.BREAK_GLASS_VERIFY_CODE.length >= 32 ? createHash("sha256") .update(`${config.COOKIE_SECRET}:${config.BREAK_GLASS_VERIFY_CODE}`) .digest("hex") : null; const hasSiteAccessBypass = (req: { cookies?: Record }) => { const candidate = req.cookies?.[SITE_ACCESS_COOKIE]; if (!siteAccessExpectedToken || typeof candidate !== "string") return false; return safeEqual(candidate, siteAccessExpectedToken); }; const app = Fastify({ logger: true, trustProxy: config.NODE_ENV === "production", requestIdHeader: "x-request-id", genReqId: (req) => { const hdr = req.headers["x-request-id"]; if (typeof hdr === "string" && hdr.length <= 64) return hdr; return Math.random().toString(36).slice(2) + Date.now().toString(36); }, }); const toBig = (n: number | string | bigint) => BigInt(n); const parseCurrencyToCents = (value: string): number => { const cleaned = value.replace(/[^0-9.]/g, ""); const [whole, fraction = ""] = cleaned.split("."); const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; const parsed = Number.parseFloat(normalized || "0"); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; }; const addMonths = (date: Date, months: number) => { const next = new Date(date); next.setMonth(next.getMonth() + months); return next; }; const ensureCsrfCookie = (reply: any, existing?: string) => { const token = existing ?? randomUUID().replace(/-/g, ""); reply.setCookie(CSRF_COOKIE, token, { httpOnly: false, sameSite: "lax", secure: config.NODE_ENV === "production", path: "/", ...(cookieDomain ? { domain: cookieDomain } : {}), }); return token; }; const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds const PASSWORD_RESET_MIN_TOKEN_BYTES = 32; const normalizeOrigin = (origin: string) => origin.replace(/\/$/, ""); const normalizeEmail = (email: string) => email.trim().toLowerCase(); const fingerprintEmail = (email: string) => createHash("sha256").update(normalizeEmail(email)).digest("hex").slice(0, 16); const logSecurityEvent = ( req: { id?: string; ip?: string; headers?: Record; log: FastifyInstance["log"] }, event: string, outcome: "success" | "failure" | "blocked", details?: Record ) => { req.log.warn({ securityEvent: event, outcome, requestId: String(req.id ?? ""), ip: (req.ip || "").replace("::ffff:", ""), userAgent: req.headers?.["user-agent"], ...details, }); }; const toPlainEmailAddress = (value?: string | null) => { if (!value) return undefined; const trimmed = value.trim(); const match = trimmed.match(/<([^>]+)>/); return (match?.[1] ?? trimmed).trim(); }; const mailer = config.SMTP_HOST ? nodemailer.createTransport({ host: config.SMTP_HOST, port: Number(config.SMTP_PORT ?? 587), secure: false, requireTLS: config.SMTP_REQUIRE_TLS, tls: { rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED, }, auth: config.SMTP_USER ? { user: config.SMTP_USER, pass: config.SMTP_PASS, } : undefined, logger: !isProd, debug: !isProd, }) : null; function buildEmailTextBody(content: string) { return `${content}\n\n---\nSkyMoney Budget\nhttps://skymoneybudget.com\nNeed help? support@skymoneybudget.com`; } function buildEmailHtmlBody(contentHtml: string) { return `${contentHtml}

SkyMoney Budget
skymoneybudget.com
Need help? support@skymoneybudget.com

`; } async function sendEmail({ to, subject, text, html, }: { to: string; subject: string; text: string; html?: string; }) { const finalText = buildEmailTextBody(text); const finalHtml = buildEmailHtmlBody(html ?? `

${text}

`); if (!mailer) { // Avoid exposing one-time codes in logs when SMTP is unavailable. app.log.warn({ to, subject }, "[email] mailer disabled; email not sent"); return; } const bounceFrom = toPlainEmailAddress(config.EMAIL_BOUNCE_FROM) ?? toPlainEmailAddress(config.EMAIL_FROM) ?? "bounces@skymoneybudget.com"; try { const info = await mailer.sendMail({ from: config.EMAIL_FROM, to, subject, text: finalText, html: finalHtml, replyTo: config.EMAIL_REPLY_TO || undefined, envelope: { from: bounceFrom, to: [to], }, headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All", }, }); app.log.info({ to, subject, messageId: info.messageId }, "[email] sent"); } catch (err) { app.log.error({ err, to, subject }, "[email] failed"); throw err; } } function generateCode(length = 6) { const min = 10 ** (length - 1); const max = 10 ** length - 1; return String(randomInt(min, max + 1)); } function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } function safeEqual(a: string, b: string): boolean { const left = Buffer.from(a, "utf8"); const right = Buffer.from(b, "utf8"); if (left.length !== right.length) return false; return timingSafeEqual(left, right); } async function issueEmailToken( userId: string, type: EmailTokenType, ttlMs: number, token?: string ) { const code = token ?? generateCode(6); const tokenHash = hashToken(code); const expiresAt = new Date(Date.now() + ttlMs); await app.prisma.emailToken.create({ data: { userId, type, tokenHash, expiresAt, }, }); return { code, expiresAt }; } function generatePasswordResetToken() { return randomBytes(PASSWORD_RESET_MIN_TOKEN_BYTES).toString("base64url"); } type EmailTokenType = "signup" | "delete" | "password_reset"; async function assertEmailTokenCooldown( userId: string, type: EmailTokenType, cooldownMs: number ) { if (cooldownMs <= 0) return; const recent = await app.prisma.emailToken.findFirst({ where: { userId, type, createdAt: { gte: new Date(Date.now() - cooldownMs) }, }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }); if (!recent) return; const elapsedMs = Date.now() - recent.createdAt.getTime(); const retryAfterSeconds = Math.max(1, Math.ceil((cooldownMs - elapsedMs) / 1000)); const err: any = new Error("Please wait before requesting another code."); err.statusCode = 429; err.code = "EMAIL_TOKEN_COOLDOWN"; err.retryAfterSeconds = retryAfterSeconds; throw err; } async function clearEmailTokens(userId: string, type?: EmailTokenType) { await app.prisma.emailToken.deleteMany({ where: type ? { userId, type } : { userId }, }); } type LoginAttemptState = { failedAttempts: number; lockedUntilMs: number; }; const loginAttemptStateByEmail = new Map(); const getLoginLockout = (email: string) => { const key = normalizeEmail(email); const state = loginAttemptStateByEmail.get(key); if (!state) return { locked: false as const }; if (state.lockedUntilMs > Date.now()) { const retryAfterSeconds = Math.max( 1, Math.ceil((state.lockedUntilMs - Date.now()) / 1000) ); return { locked: true as const, retryAfterSeconds }; } if (state.lockedUntilMs > 0) { loginAttemptStateByEmail.delete(key); } return { locked: false as const }; }; const registerFailedLoginAttempt = (email: string) => { const key = normalizeEmail(email); const now = Date.now(); const current = loginAttemptStateByEmail.get(key); const failedAttempts = (current?.failedAttempts ?? 0) + 1; if (failedAttempts >= config.AUTH_MAX_FAILED_ATTEMPTS) { const lockedUntilMs = now + config.AUTH_LOCKOUT_WINDOW_MS; loginAttemptStateByEmail.set(key, { failedAttempts: 0, lockedUntilMs }); return { locked: true as const, retryAfterSeconds: Math.ceil(config.AUTH_LOCKOUT_WINDOW_MS / 1000) }; } loginAttemptStateByEmail.set(key, { failedAttempts, lockedUntilMs: 0 }); return { locked: false as const }; }; const clearFailedLoginAttempts = (email: string) => { loginAttemptStateByEmail.delete(normalizeEmail(email)); }; /** * Calculate the next due date based on frequency for rollover */ function calculateNextDueDate(currentDueDate: Date, frequency: string, timezone: string = "UTC"): Date { const base = getUserMidnightFromDateOnly(timezone, currentDueDate); const zoned = toZonedTime(base, timezone); switch (frequency) { case "weekly": zoned.setUTCDate(zoned.getUTCDate() + 7); break; case "biweekly": zoned.setUTCDate(zoned.getUTCDate() + 14); break; case "monthly": { const targetDay = zoned.getUTCDate(); const nextMonth = zoned.getUTCMonth() + 1; const nextYear = zoned.getUTCFullYear() + Math.floor(nextMonth / 12); const nextMonthIndex = nextMonth % 12; const lastDay = new Date(Date.UTC(nextYear, nextMonthIndex + 1, 0)).getUTCDate(); zoned.setUTCFullYear(nextYear, nextMonthIndex, Math.min(targetDay, lastDay)); break; } default: return base; } zoned.setUTCHours(0, 0, 0, 0); return fromZonedTime(zoned, timezone); } function jsonBigIntSafe(obj: unknown) { return JSON.parse( JSON.stringify(obj, (_k, v) => (typeof v === "bigint" ? Number(v) : v)) ); } type PercentCategory = { id: string; percent: number; balanceCents: bigint | null; }; function computePercentShares(categories: PercentCategory[], amountCents: number) { const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; const shares = categories.map((cat) => { const raw = (amountCents * cat.percent) / percentTotal; const floored = Math.floor(raw); return { id: cat.id, balanceCents: Number(cat.balanceCents ?? 0n), share: floored, frac: raw - floored, }; }); let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); shares .slice() .sort((a, b) => b.frac - a.frac) .forEach((s) => { if (remainder > 0) { s.share += 1; remainder -= 1; } }); if (shares.some((s) => s.share > s.balanceCents)) { return { ok: false as const, reason: "insufficient_balances" }; } return { ok: true as const, shares }; } function computeWithdrawShares(categories: PercentCategory[], amountCents: number) { const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; const working = categories.map((cat) => ({ id: cat.id, percent: cat.percent, balanceCents: Number(cat.balanceCents ?? 0n), share: 0, })); let remaining = Math.max(0, Math.floor(amountCents)); let safety = 0; while (remaining > 0 && safety < 1000) { safety += 1; const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0); if (eligible.length === 0) break; const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0); if (totalPercent <= 0) break; const provisional = eligible.map((cat) => { const raw = (remaining * cat.percent) / totalPercent; const floored = Math.floor(raw); return { id: cat.id, raw, floored, remainder: raw - floored, }; }); let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0); let leftovers = remaining - sumBase; provisional .slice() .sort((a, b) => b.remainder - a.remainder) .forEach((p) => { if (leftovers > 0) { p.floored += 1; leftovers -= 1; } }); let allocatedThisRound = 0; for (const p of provisional) { const entry = working.find((w) => w.id === p.id); if (!entry) continue; const take = Math.min(p.floored, entry.balanceCents); if (take > 0) { entry.balanceCents -= take; entry.share += take; allocatedThisRound += take; } } remaining -= allocatedThisRound; if (allocatedThisRound === 0) break; } if (remaining > 0) { return { ok: false as const, reason: "insufficient_balances" }; } return { ok: true as const, shares: working.map((c) => ({ id: c.id, share: c.share })), }; } function computeOverdraftShares(categories: PercentCategory[], amountCents: number) { const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; const shares = categories.map((cat) => { const raw = (amountCents * cat.percent) / percentTotal; const floored = Math.floor(raw); return { id: cat.id, share: floored, frac: raw - floored, }; }); let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); shares .slice() .sort((a, b) => b.frac - a.frac) .forEach((s) => { if (remainder > 0) { s.share += 1; remainder -= 1; } }); return { ok: true as const, shares }; } function computeDepositShares(categories: PercentCategory[], amountCents: number) { const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); if (percentTotal <= 0) return { ok: false as const, reason: "no_percent" }; const shares = categories.map((cat) => { const raw = (amountCents * cat.percent) / percentTotal; const floored = Math.floor(raw); return { id: cat.id, share: floored, frac: raw - floored, }; }); let remainder = amountCents - shares.reduce((sum, s) => sum + s.share, 0); shares .slice() .sort((a, b) => b.frac - a.frac) .forEach((s) => { if (remainder > 0) { s.share += 1; remainder -= 1; } }); return { ok: true as const, shares }; } const DEFAULT_VARIABLE_CATEGORIES = [ { name: "Essentials", percent: 50, priority: 10, isSavings: false }, { name: "Savings", percent: 30, priority: 20, isSavings: true }, { name: "Fun", percent: 20, priority: 30, isSavings: false }, ] as const; const DEFAULT_FIXED_PLANS = [ { name: "Rent", totalCents: 120_000, priority: 10 }, ] as const; async function seedDefaultBudget(prisma: PrismaClient, userId: string) { const [catCount, planCount] = await Promise.all([ prisma.variableCategory.count({ where: { userId } }), prisma.fixedPlan.count({ where: { userId } }), ]); if (catCount > 0 && planCount > 0) return; const now = new Date(); const nextDue = addMonths(new Date(now.getFullYear(), now.getMonth(), 1), 1); await prisma.$transaction(async (tx) => { if (catCount === 0) { await tx.variableCategory.createMany({ data: DEFAULT_VARIABLE_CATEGORIES.map((cat, idx) => ({ userId, name: cat.name, percent: cat.percent, priority: cat.priority + idx, isSavings: cat.isSavings, balanceCents: 0n, })), }); } if (planCount === 0) { await Promise.all( DEFAULT_FIXED_PLANS.map((plan, idx) => tx.fixedPlan.create({ data: { userId, name: plan.name, totalCents: toBig(plan.totalCents), fundedCents: 0n, currentFundedCents: 0n, priority: plan.priority + idx, cycleStart: now, dueOn: nextDue, fundingMode: "auto-on-deposit", }, }) ) ); } }); } const configuredOrigins = (config.CORS_ORIGINS || config.CORS_ORIGIN || "") .split(",") .map((s) => normalizeOrigin(s.trim())) .filter(Boolean); await app.register(cors, { origin: (() => { // Keep local/dev friction-free. if (config.NODE_ENV !== "production") return true; return (origin, cb) => { if (!origin) return cb(null, true); const normalized = normalizeOrigin(origin); cb(null, configuredOrigins.includes(normalized)); }; })(), credentials: true, }); await app.register(rateLimit, { max: config.RATE_LIMIT_MAX, timeWindow: config.RATE_LIMIT_WINDOW_MS, hook: "onRequest", }); await app.register(fastifyCookie, { secret: config.COOKIE_SECRET }); await app.register(fastifyJwt, { secret: config.JWT_SECRET, cookie: { cookieName: "session", signed: false }, verify: { algorithms: ["HS256"], allowedIss: config.JWT_ISSUER, allowedAud: config.JWT_AUDIENCE, }, sign: { algorithm: "HS256", iss: config.JWT_ISSUER, aud: config.JWT_AUDIENCE, expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`, }, }); app.addHook("onReady", async () => { if (!mailer) return; try { await mailer.verify(); app.log.info("[email] SMTP verified"); } catch (err) { app.log.error({ err }, "[email] SMTP verify failed"); if (config.NODE_ENV === "production") throw err; } }); { const prisma = new PrismaClient(); app.decorate("prisma", prisma); app.addHook("onClose", async () => prisma.$disconnect()); } app.decorate("ensureUser", async (userId: string) => { await app.prisma.user.upsert({ where: { id: userId }, update: {}, create: { id: userId, email: `${userId}@demo.local`, displayName: null }, }); if (config.SEED_DEFAULT_BUDGET) { await seedDefaultBudget(app.prisma, userId); } }); app.addHook("onRequest", async (req, reply) => { reply.header("x-request-id", String(req.id ?? "")); const path = pathOf(req.url ?? ""); const isSiteAccessPath = path === "/site-access/status" || path === "/site-access/unlock" || path === "/site-access/lock"; if ( config.UNDER_CONSTRUCTION_ENABLED && !isSiteAccessPath && path !== "/health" && path !== "/health/db" && !hasSiteAccessBypass(req) ) { return reply.code(503).send({ ok: false, code: "UNDER_CONSTRUCTION", message: "SkyMoney is temporarily under construction.", requestId: String(req.id ?? ""), }); } // Open paths don't require authentication if (openPaths.has(path)) { return; } // If auth is disabled, require x-user-id header (no more demo-user-1 fallback) if (config.AUTH_DISABLED) { const userIdHeader = req.headers["x-user-id"]?.toString().trim(); if (!userIdHeader) { logSecurityEvent(req, "auth.missing_user_id_header", "failure"); return reply.code(401).send({ error: "No user ID provided" }); } req.userId = userIdHeader; await app.ensureUser(req.userId); return; } try { const { sub, iat } = await req.jwtVerify<{ sub: string; iat?: number }>(); const authUser = await app.prisma.user.findUnique({ where: { id: sub }, select: { id: true, passwordChangedAt: true }, }); if (!authUser) { logSecurityEvent(req, "auth.unauthenticated_request", "failure", { path, method: req.method, reason: "unknown_user", }); return reply .code(401) .send({ ok: false, code: "UNAUTHENTICATED", message: "Login required", requestId: String(req.id ?? ""), }); } const issuedAtMs = typeof iat === "number" && Number.isFinite(iat) ? Math.floor(iat * 1000) : null; const passwordChangedAtMs = authUser.passwordChangedAt?.getTime() ?? null; if (passwordChangedAtMs !== null && (issuedAtMs === null || issuedAtMs <= passwordChangedAtMs)) { logSecurityEvent(req, "auth.unauthenticated_request", "failure", { path, method: req.method, reason: "stale_session", userId: sub, }); return reply .code(401) .send({ ok: false, code: "UNAUTHENTICATED", message: "Login required", requestId: String(req.id ?? ""), }); } req.userId = sub; if (config.SEED_DEFAULT_BUDGET) { await seedDefaultBudget(app.prisma, req.userId); } } catch { logSecurityEvent(req, "auth.unauthenticated_request", "failure", { path, method: req.method, }); return reply .code(401) .send({ ok: false, code: "UNAUTHENTICATED", message: "Login required", requestId: String(req.id ?? ""), }); } }); app.addHook("preHandler", async (req, reply) => { const path = pathOf(req.url ?? ""); const method = req.method.toUpperCase(); if (method === "GET" || method === "HEAD" || method === "OPTIONS") { return; } if ( path === "/site-access/unlock" || path === "/site-access/lock" || path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend" || path === "/auth/forgot-password/request" || path === "/auth/forgot-password/confirm" ) { return; } const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined; if (!cookieToken || !headerToken || cookieToken !== headerToken) { logSecurityEvent(req, "csrf.validation", "failure", { path }); return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" }); } }); await app.register(sessionRoutes, { config, cookieDomain, mutationRateLimit, }); await app.register(userRoutes); await app.register(healthRoutes, { nodeEnv: config.NODE_ENV }); await app.register(authAccountRoutes, { config, cookieDomain, exposeDevVerificationCode, authRateLimit, codeVerificationRateLimit, codeIssueRateLimit, passwordResetRequestRateLimit: passwordResetRequestRateLimit( config.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE ), passwordResetConfirmRateLimit: passwordResetConfirmRateLimit( config.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE ), emailTokenTtlMs: EMAIL_TOKEN_TTL_MS, deleteTokenTtlMs: DELETE_TOKEN_TTL_MS, emailTokenCooldownMs: EMAIL_TOKEN_COOLDOWN_MS, normalizeEmail, fingerprintEmail, logSecurityEvent, getLoginLockout, registerFailedLoginAttempt, clearFailedLoginAttempts, seedDefaultBudget, clearEmailTokens, issueEmailToken, assertEmailTokenCooldown, sendEmail, hashToken, generatePasswordResetToken, ensureCsrfCookie, }); await app.register(variableCategoriesRoutes, { mutationRateLimit, computeDepositShares, }); await app.register(transactionsRoutes, { mutationRateLimit, computeDepositShares, computeWithdrawShares, computeOverdraftShares, calculateNextDueDate, toBig, parseCurrencyToCents, }); await app.register(fixedPlansRoutes, { mutationRateLimit, computeDepositShares, computeWithdrawShares, calculateNextDueDate, toBig, }); await app.register(incomeRoutes, { mutationRateLimit, }); await app.register(paydayRoutes, { mutationRateLimit, isProd, }); await app.register(budgetRoutes, { mutationRateLimit, computeDepositShares, computeWithdrawShares, isProd, }); await app.register(dashboardRoutes); await app.register(siteAccessRoutes, { underConstructionEnabled: config.UNDER_CONSTRUCTION_ENABLED, breakGlassVerifyEnabled: config.BREAK_GLASS_VERIFY_ENABLED, breakGlassVerifyCode: config.BREAK_GLASS_VERIFY_CODE ?? null, siteAccessExpectedToken, cookieDomain, secureCookie: config.NODE_ENV === "production", siteAccessCookieName: SITE_ACCESS_COOKIE, siteAccessMaxAgeSeconds: SITE_ACCESS_MAX_AGE_SECONDS, authRateLimit, mutationRateLimit, hasSiteAccessBypass, safeEqual, }); await app.register(adminRoutes, { authDisabled: config.AUTH_DISABLED, isInternalClientIp, }); app.addHook("preSerialization", (_req, _reply, payload, done) => { try { if (payload && typeof payload === "object") { const safe = JSON.parse( JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? Number(v) : v)) ); return done(null, safe); } return done(null, payload); } catch { return done(null, payload); } }); app.setErrorHandler((err, req, reply) => { const status = (typeof (err as any).statusCode === "number" && (err as any).statusCode) || (typeof (err as any).status === "number" && (err as any).status) || (typeof (err as any).code === "string" && (err as any).code.startsWith("P2") ? 400 : 500); const body = { ok: false, code: (err as any).code ?? "INTERNAL", message: status >= 500 ? "Something went wrong" : (err as any).message ?? "Bad request", requestId: String(req.id ?? ""), }; req.log.error({ err, requestId: req.id }, "request failed"); reply.code(status).send(body); }); app.setNotFoundHandler((req, reply) => { reply.code(404).send({ ok: false, code: "NOT_FOUND", message: `No route: ${req.method} ${req.url}`, }); }); return app; } const PORT = env.PORT; const HOST = process.env.HOST || "0.0.0.0"; const app = await buildApp(); export default app; if (process.env.NODE_ENV !== "test") { app.listen({ port: PORT, host: HOST }).catch((err) => { app.log.error(err); process.exit(1); }); }