Files
SkyMoney/api/src/server.ts
Ricearoni1245 952684fc25
All checks were successful
Deploy / deploy (push) Successful in 1m32s
Security Tests / security-non-db (push) Successful in 21s
Security Tests / security-db (push) Successful in 27s
phase 8: site-access and admin simplified and compacted
2026-03-18 06:43:19 -05:00

1020 lines
29 KiB
TypeScript

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<string, unknown>)?.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<string, unknown>)?.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<void>;
}
interface FastifyRequest {
userId: string;
}
}
export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<FastifyInstance> {
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<string, unknown> }) => {
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<string, any>; log: FastifyInstance["log"] },
event: string,
outcome: "success" | "failure" | "blocked",
details?: Record<string, unknown>
) => {
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}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;"/>
<p style="margin:0;color:#6b7280;font-size:12px;line-height:1.5;">
SkyMoney Budget<br/>
<a href="https://skymoneybudget.com">skymoneybudget.com</a><br/>
Need help? <a href="mailto:support@skymoneybudget.com">support@skymoneybudget.com</a>
</p>`;
}
async function sendEmail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html?: string;
}) {
const finalText = buildEmailTextBody(text);
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
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<string, LoginAttemptState>();
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);
});
}