diff --git a/api/src/routes/health.ts b/api/src/routes/health.ts new file mode 100644 index 0000000..6d0d594 --- /dev/null +++ b/api/src/routes/health.ts @@ -0,0 +1,24 @@ +import type { FastifyPluginAsync } from "fastify"; + +type HealthRoutesOptions = { + nodeEnv: string; +}; + +const healthRoutes: FastifyPluginAsync = async ( + app, + opts +) => { + app.get("/health", async () => ({ ok: true })); + + if (opts.nodeEnv !== "production") { + app.get("/health/db", async () => { + const start = Date.now(); + const [{ now }] = + await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`; + const latencyMs = Date.now() - start; + return { ok: true, nowISO: now.toISOString(), latencyMs }; + }); + } +}; + +export default healthRoutes; diff --git a/api/src/routes/session.ts b/api/src/routes/session.ts new file mode 100644 index 0000000..41efe16 --- /dev/null +++ b/api/src/routes/session.ts @@ -0,0 +1,101 @@ +import type { FastifyPluginAsync } from "fastify"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { AppConfig } from "../server.js"; + +type SessionRoutesOptions = { + config: Pick< + AppConfig, + "NODE_ENV" | "UPDATE_NOTICE_VERSION" | "UPDATE_NOTICE_TITLE" | "UPDATE_NOTICE_BODY" + >; + cookieDomain?: string; + mutationRateLimit: { + config: { + rateLimit: { + max: number; + timeWindow: number; + }; + }; + }; +}; + +const CSRF_COOKIE = "csrf"; + +const sessionRoutes: FastifyPluginAsync = async ( + app, + opts +) => { + const ensureCsrfCookie = (reply: any, existing?: string) => { + const token = existing ?? randomUUID().replace(/-/g, ""); + reply.setCookie(CSRF_COOKIE, token, { + httpOnly: false, + sameSite: "lax", + secure: opts.config.NODE_ENV === "production", + path: "/", + ...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}), + }); + return token; + }; + + app.get("/auth/session", async (req, reply) => { + if (!(req.cookies as any)?.[CSRF_COOKIE]) { + ensureCsrfCookie(reply); + } + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { + email: true, + displayName: true, + emailVerified: true, + seenUpdateVersion: true, + }, + }); + const noticeVersion = opts.config.UPDATE_NOTICE_VERSION; + const shouldShowNotice = + noticeVersion > 0 && + !!user && + user.emailVerified && + user.seenUpdateVersion < noticeVersion; + + return { + ok: true, + userId: req.userId, + email: user?.email ?? null, + displayName: user?.displayName ?? null, + emailVerified: user?.emailVerified ?? false, + updateNotice: shouldShowNotice + ? { + version: noticeVersion, + title: opts.config.UPDATE_NOTICE_TITLE, + body: opts.config.UPDATE_NOTICE_BODY, + } + : null, + }; + }); + + app.post( + "/app/update-notice/ack", + opts.mutationRateLimit, + async (req, reply) => { + const Body = z.object({ + version: z.coerce.number().int().nonnegative().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const targetVersion = + parsed.data.version ?? opts.config.UPDATE_NOTICE_VERSION; + await app.prisma.user.updateMany({ + where: { + id: req.userId, + seenUpdateVersion: { lt: targetVersion }, + }, + data: { seenUpdateVersion: targetVersion }, + }); + return { ok: true }; + } + ); +}; + +export default sessionRoutes; diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 0000000..1834bfb --- /dev/null +++ b/api/src/routes/user.ts @@ -0,0 +1,216 @@ +import type { FastifyPluginAsync } from "fastify"; +import argon2 from "argon2"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { getUserMidnightFromDateOnly } from "../allocator.js"; + +const PASSWORD_MIN_LENGTH = 12; +const passwordSchema = z + .string() + .min(PASSWORD_MIN_LENGTH) + .max(128) + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a symbol"); + +const HASH_OPTIONS: argon2.Options & { raw?: false } = { + type: argon2.argon2id, + memoryCost: 19_456, + timeCost: 3, + parallelism: 1, +}; + +const UserConfigBody = z.object({ + incomeType: z.enum(["regular", "irregular"]).optional(), + totalBudgetCents: z.number().int().nonnegative().optional(), + budgetPeriod: z.enum(["weekly", "biweekly", "monthly"]).optional(), + incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]).optional(), + firstIncomeDate: z + .union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]) + .nullable() + .optional(), + timezone: z + .string() + .refine((value) => { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format( + new Date() + ); + return true; + } catch { + return false; + } + }, "Invalid timezone") + .optional(), + fixedExpensePercentage: z.number().int().min(0).max(100).optional(), +}); + +const userRoutes: FastifyPluginAsync = async (app) => { + app.patch("/me", async (req, reply) => { + const Body = z.object({ + displayName: z.string().trim().min(1).max(120), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const updated = await app.prisma.user.update({ + where: { id: req.userId }, + data: { displayName: parsed.data.displayName.trim() }, + select: { id: true, email: true, displayName: true }, + }); + return { + ok: true, + userId: updated.id, + email: updated.email, + displayName: updated.displayName, + }; + }); + + app.patch("/me/password", async (req, reply) => { + const Body = z.object({ + currentPassword: z.string().min(1), + newPassword: passwordSchema, + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply + .code(400) + .send({ ok: false, message: "Invalid password data" }); + } + + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { passwordHash: true }, + }); + + if (!user?.passwordHash) { + return reply.code(401).send({ ok: false, message: "No password set" }); + } + + const valid = await argon2.verify( + user.passwordHash, + parsed.data.currentPassword + ); + if (!valid) { + return reply + .code(401) + .send({ ok: false, message: "Current password is incorrect" }); + } + + const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS); + + await app.prisma.user.update({ + where: { id: req.userId }, + data: { passwordHash: newHash, passwordChangedAt: new Date() }, + }); + + return { ok: true, message: "Password updated successfully" }; + }); + + app.patch("/me/income-frequency", async (req, reply) => { + const Body = z.object({ + incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply + .code(400) + .send({ ok: false, message: "Invalid income frequency data" }); + } + const updated = await app.prisma.user.update({ + where: { id: req.userId }, + data: { + incomeFrequency: parsed.data.incomeFrequency, + }, + select: { id: true, incomeFrequency: true }, + }); + return { + ok: true, + incomeFrequency: updated.incomeFrequency, + }; + }); + + app.patch("/user/config", async (req, reply) => { + const parsed = UserConfigBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ message: "Invalid user config data" }); + } + + const userId = req.userId; + const updateData: any = {}; + const scheduleChange = + parsed.data.incomeFrequency !== undefined || + parsed.data.firstIncomeDate !== undefined; + const wantsFirstIncomeDate = parsed.data.firstIncomeDate !== undefined; + + if (parsed.data.incomeFrequency) + updateData.incomeFrequency = parsed.data.incomeFrequency; + if (parsed.data.totalBudgetCents !== undefined) + updateData.totalBudgetCents = BigInt(parsed.data.totalBudgetCents); + if (parsed.data.budgetPeriod) updateData.budgetPeriod = parsed.data.budgetPeriod; + if (parsed.data.incomeType) updateData.incomeType = parsed.data.incomeType; + if (parsed.data.timezone) updateData.timezone = parsed.data.timezone; + if (parsed.data.fixedExpensePercentage !== undefined) { + updateData.fixedExpensePercentage = parsed.data.fixedExpensePercentage; + } + + const updated = await app.prisma.$transaction(async (tx) => { + const existing = await tx.user.findUnique({ + where: { id: userId }, + select: { incomeType: true, timezone: true }, + }); + const effectiveTimezone = + parsed.data.timezone ?? existing?.timezone ?? "America/New_York"; + if (wantsFirstIncomeDate) { + updateData.firstIncomeDate = parsed.data.firstIncomeDate + ? getUserMidnightFromDateOnly( + effectiveTimezone, + new Date(parsed.data.firstIncomeDate) + ) + : null; + } + const updatedUser = await tx.user.update({ + where: { id: userId }, + data: updateData, + select: { + incomeFrequency: true, + incomeType: true, + totalBudgetCents: true, + budgetPeriod: true, + firstIncomeDate: true, + timezone: true, + fixedExpensePercentage: true, + }, + }); + const finalIncomeType = updateData.incomeType ?? existing?.incomeType ?? "regular"; + if (scheduleChange && finalIncomeType === "regular") { + await tx.fixedPlan.updateMany({ + where: { userId, paymentSchedule: { not: Prisma.DbNull } }, + data: { needsFundingThisPeriod: true }, + }); + } + return updatedUser; + }); + + return { + incomeFrequency: updated.incomeFrequency, + incomeType: updated.incomeType || "regular", + totalBudgetCents: updated.totalBudgetCents + ? Number(updated.totalBudgetCents) + : null, + budgetPeriod: updated.budgetPeriod, + firstIncomeDate: updated.firstIncomeDate + ? getUserMidnightFromDateOnly( + updated.timezone ?? "America/New_York", + updated.firstIncomeDate + ).toISOString() + : null, + timezone: updated.timezone, + fixedExpensePercentage: updated.fixedExpensePercentage ?? 40, + }; + }); +}; + +export default userRoutes; diff --git a/api/src/server.ts b/api/src/server.ts index b3f8c59..63e7183 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -12,6 +12,9 @@ import { z } from "zod"; import { allocateIncome, allocateIncomeManual, previewAllocation, allocateBudget, applyIrregularIncome, countPayPeriodsBetween, getUserMidnight, getUserMidnightFromDateOnly, getUserDateRangeFromDateOnly } from "./allocator.js"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { rolloverFixedPlans } from "./jobs/rollover.js"; +import healthRoutes from "./routes/health.js"; +import sessionRoutes from "./routes/session.js"; +import userRoutes from "./routes/user.js"; export type AppConfig = typeof env; @@ -1512,41 +1515,13 @@ app.post("/auth/refresh", async (req, reply) => { return { ok: true, expiresInMinutes: config.SESSION_TIMEOUT_MINUTES }; }); -app.get("/auth/session", async (req, reply) => { - if (!(req.cookies as any)?.[CSRF_COOKIE]) { - ensureCsrfCookie(reply); - } - const user = await app.prisma.user.findUnique({ - where: { id: req.userId }, - select: { - email: true, - displayName: true, - emailVerified: true, - seenUpdateVersion: true, - }, - }); - const noticeVersion = config.UPDATE_NOTICE_VERSION; - const shouldShowNotice = - noticeVersion > 0 && - !!user && - user.emailVerified && - user.seenUpdateVersion < noticeVersion; - - return { - ok: true, - userId: req.userId, - email: user?.email ?? null, - displayName: user?.displayName ?? null, - emailVerified: user?.emailVerified ?? false, - updateNotice: shouldShowNotice - ? { - version: noticeVersion, - title: config.UPDATE_NOTICE_TITLE, - body: config.UPDATE_NOTICE_BODY, - } - : null, - }; +await app.register(sessionRoutes, { + config, + cookieDomain, + mutationRateLimit, }); +await app.register(userRoutes); +await app.register(healthRoutes, { nodeEnv: config.NODE_ENV }); app.get("/site-access/status", async (req) => { if (!config.UNDER_CONSTRUCTION_ENABLED) { @@ -1607,99 +1582,6 @@ app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => { return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false }; }); -app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => { - const Body = z.object({ - version: z.coerce.number().int().nonnegative().optional(), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const targetVersion = parsed.data.version ?? config.UPDATE_NOTICE_VERSION; - await app.prisma.user.updateMany({ - where: { - id: req.userId, - seenUpdateVersion: { lt: targetVersion }, - }, - data: { seenUpdateVersion: targetVersion }, - }); - return { ok: true }; -}); - -app.patch("/me", async (req, reply) => { - const Body = z.object({ - displayName: z.string().trim().min(1).max(120), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid payload" }); - } - const updated = await app.prisma.user.update({ - where: { id: req.userId }, - data: { displayName: parsed.data.displayName.trim() }, - select: { id: true, email: true, displayName: true }, - }); - return { ok: true, userId: updated.id, email: updated.email, displayName: updated.displayName }; -}); - -app.patch("/me/password", async (req, reply) => { - const Body = z.object({ - currentPassword: z.string().min(1), - newPassword: passwordSchema, - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid password data" }); - } - - const user = await app.prisma.user.findUnique({ - where: { id: req.userId }, - select: { passwordHash: true }, - }); - - if (!user?.passwordHash) { - return reply.code(401).send({ ok: false, message: "No password set" }); - } - - // Verify current password - const valid = await argon2.verify(user.passwordHash, parsed.data.currentPassword); - if (!valid) { - return reply.code(401).send({ ok: false, message: "Current password is incorrect" }); - } - - // Hash new password - const newHash = await argon2.hash(parsed.data.newPassword, HASH_OPTIONS); - - // Update password - await app.prisma.user.update({ - where: { id: req.userId }, - data: { passwordHash: newHash, passwordChangedAt: new Date() }, - }); - - return { ok: true, message: "Password updated successfully" }; -}); - -app.patch("/me/income-frequency", async (req, reply) => { - const Body = z.object({ - incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]), - }); - const parsed = Body.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ ok: false, message: "Invalid income frequency data" }); - } - const updated = await app.prisma.user.update({ - where: { id: req.userId }, - data: { - incomeFrequency: parsed.data.incomeFrequency, - }, - select: { id: true, incomeFrequency: true }, - }); - return { - ok: true, - incomeFrequency: updated.incomeFrequency, - }; -}); - app.addHook("preSerialization", (_req, _reply, payload, done) => { try { if (payload && typeof payload === "object") { @@ -1761,18 +1643,6 @@ app.post("/admin/rollover", async (req, reply) => { return { ok: true, asOf: asOf.toISOString(), dryRun, processed: results.length, results }; }); -// ----- Health ----- -app.get("/health", async () => ({ ok: true })); -if (config.NODE_ENV !== "production") { - app.get("/health/db", async () => { - const start = Date.now(); - const [{ now }] = - await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`; - const latencyMs = Date.now() - start; - return { ok: true, nowISO: now.toISOString(), latencyMs }; - }); -} - // ----- Dashboard ----- app.get("/dashboard", async (req) => { const userId = req.userId; @@ -4825,95 +4695,6 @@ app.post("/budget/reconcile", mutationRateLimit, async (req, reply) => { }); }); -const UserConfigBody = z.object({ - incomeType: z.enum(["regular", "irregular"]).optional(), - totalBudgetCents: z.number().int().nonnegative().optional(), - budgetPeriod: z.enum(["weekly", "biweekly", "monthly"]).optional(), - incomeFrequency: z.enum(["weekly", "biweekly", "monthly"]).optional(), - firstIncomeDate: z - .union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]) - .nullable() - .optional(), - timezone: z.string().refine((value) => { - try { - new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); - return true; - } catch { - return false; - } - }, "Invalid timezone").optional(), // IANA timezone identifier - fixedExpensePercentage: z.number().int().min(0).max(100).optional(), -}); - -app.patch("/user/config", async (req, reply) => { - const parsed = UserConfigBody.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send({ message: "Invalid user config data" }); - } - - const userId = req.userId; - const updateData: any = {}; - const scheduleChange = - parsed.data.incomeFrequency !== undefined || - parsed.data.firstIncomeDate !== undefined; - const wantsFirstIncomeDate = parsed.data.firstIncomeDate !== undefined; - - if (parsed.data.incomeFrequency) updateData.incomeFrequency = parsed.data.incomeFrequency; - if (parsed.data.totalBudgetCents !== undefined) updateData.totalBudgetCents = BigInt(parsed.data.totalBudgetCents); - if (parsed.data.budgetPeriod) updateData.budgetPeriod = parsed.data.budgetPeriod; - if (parsed.data.incomeType) updateData.incomeType = parsed.data.incomeType; - if (parsed.data.timezone) updateData.timezone = parsed.data.timezone; - if (parsed.data.fixedExpensePercentage !== undefined) { - updateData.fixedExpensePercentage = parsed.data.fixedExpensePercentage; - } - - const updated = await app.prisma.$transaction(async (tx) => { - const existing = await tx.user.findUnique({ - where: { id: userId }, - select: { incomeType: true, timezone: true }, - }); - const effectiveTimezone = parsed.data.timezone ?? existing?.timezone ?? "America/New_York"; - if (wantsFirstIncomeDate) { - updateData.firstIncomeDate = parsed.data.firstIncomeDate - ? getUserMidnightFromDateOnly(effectiveTimezone, new Date(parsed.data.firstIncomeDate)) - : null; - } - const updatedUser = await tx.user.update({ - where: { id: userId }, - data: updateData, - select: { - incomeFrequency: true, - incomeType: true, - totalBudgetCents: true, - budgetPeriod: true, - firstIncomeDate: true, - timezone: true, - fixedExpensePercentage: true, - }, - }); - const finalIncomeType = updateData.incomeType ?? existing?.incomeType ?? "regular"; - if (scheduleChange && finalIncomeType === "regular") { - await tx.fixedPlan.updateMany({ - where: { userId, paymentSchedule: { not: Prisma.DbNull } }, - data: { needsFundingThisPeriod: true }, - }); - } - return updatedUser; - }); - - return { - incomeFrequency: updated.incomeFrequency, - incomeType: updated.incomeType || "regular", - totalBudgetCents: updated.totalBudgetCents ? Number(updated.totalBudgetCents) : null, - budgetPeriod: updated.budgetPeriod, - firstIncomeDate: updated.firstIncomeDate - ? getUserMidnightFromDateOnly(updated.timezone ?? "America/New_York", updated.firstIncomeDate).toISOString() - : null, - timezone: updated.timezone, - fixedExpensePercentage: updated.fixedExpensePercentage ?? 40, - }; -}); - return app; } diff --git a/docs/api-phase1-move-log.md b/docs/api-phase1-move-log.md new file mode 100644 index 0000000..0fd1da4 --- /dev/null +++ b/docs/api-phase1-move-log.md @@ -0,0 +1,61 @@ +# API Phase 1 Move Log + +Date: 2026-03-15 +Scope: Move low-risk endpoints out of `api/src/server.ts` into dedicated route modules. + +## Route Registration Changes +- Registered session routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1518) +- Registered user routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1523) +- Registered health routes in [server.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/server.ts:1524) + +## Endpoint Movements + +1. `GET /health` +- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:11) +- References: + - [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:10) + - [security-misconfiguration.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/security-misconfiguration.test.ts:29) + +2. `GET /health/db` (non-production only) +- Moved to [health.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/health.ts:14) +- References: + - [HealthPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/HealthPage.tsx:15) + +3. `GET /auth/session` +- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:40) +- References: + - [useAuthSession.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/hooks/useAuthSession.ts:23) + - [auth.routes.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/auth.routes.test.ts:120) + +4. `POST /app/update-notice/ack` +- Moved to [session.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/session.ts:77) +- References: + - [App.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/App.tsx:33) + +5. `PATCH /me` +- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:50) +- References: + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:394) + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:115) + +6. `PATCH /me/password` +- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:71) +- References: + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:148) + - [identification-auth-failures.test.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/tests/identification-auth-failures.test.ts:57) + +7. `PATCH /me/income-frequency` +- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:112) +- References: + - Currently no frontend direct calls found by static search. + +8. `PATCH /user/config` +- Moved to [user.ts](/mnt/c/Users/jholt/clone-test/SkyMoney/api/src/routes/user.ts:135) +- References: + - [OnboardingPage.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/OnboardingPage.tsx:408) + - [AccountSettings.tsx](/mnt/c/Users/jholt/clone-test/SkyMoney/web/src/pages/settings/AccountSettings.tsx:171) + +## Notes +- `server.ts` endpoint blocks for the above routes were removed to prevent duplicate registration. +- Existing path contracts were preserved (same method + path). +- `openPaths` and auth/CSRF hook behavior remain unchanged.