1020 lines
29 KiB
TypeScript
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);
|
|
});
|
|
}
|