feat: email verification + delete confirmation + smtp/cors/prod hardening

This commit is contained in:
2026-02-09 14:46:49 -06:00
parent 27cc7d159b
commit 9856317641
22 changed files with 896 additions and 58 deletions

35
api/package-lock.json generated
View File

@@ -17,10 +17,12 @@
"date-fns-tz": "^3.2.0",
"fastify": "^5.6.2",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.19.25",
"@types/nodemailer": "^7.0.9",
"@types/supertest": "^6.0.3",
"prisma": "^5.22.0",
"supertest": "^6.3.4",
@@ -1127,6 +1129,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.9",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
@@ -1802,9 +1814,9 @@
}
},
"node_modules/fastify": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz",
"integrity": "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==",
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
"funding": [
{
"type": "github",
@@ -1817,7 +1829,7 @@
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^4.0.0",
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
@@ -2278,6 +2290,15 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2443,9 +2464,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -19,6 +19,7 @@
},
"devDependencies": {
"@types/node": "^20.19.25",
"@types/nodemailer": "^7.0.9",
"@types/supertest": "^6.0.3",
"prisma": "^5.22.0",
"supertest": "^6.3.4",
@@ -36,6 +37,7 @@
"date-fns-tz": "^3.2.0",
"fastify": "^5.6.2",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"zod": "^3.23.8"
}
}

View File

@@ -0,0 +1,19 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS "EmailToken" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailToken_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "EmailToken_userId_type_idx" ON "EmailToken"("userId", "type");
CREATE INDEX IF NOT EXISTS "EmailToken_tokenHash_idx" ON "EmailToken"("tokenHash");
ALTER TABLE "EmailToken"
ADD CONSTRAINT "EmailToken_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,6 @@
-- Harden email token lifecycle (used marker + lookup index)
ALTER TABLE "EmailToken"
ADD COLUMN IF NOT EXISTS "usedAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "EmailToken_userId_type_expiresAt_idx"
ON "EmailToken"("userId", "type", "expiresAt");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "seenUpdateVersion" INTEGER NOT NULL DEFAULT 0;

View File

@@ -25,6 +25,8 @@ model User {
email String @unique
passwordHash String?
displayName String?
emailVerified Boolean @default(false)
seenUpdateVersion Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -43,6 +45,7 @@ model User {
allocations Allocation[]
transactions Transaction[]
budgetSessions BudgetSession[]
emailTokens EmailToken[]
}
model VariableCategory {
@@ -161,3 +164,18 @@ model BudgetSession {
@@unique([userId, periodStart])
@@index([userId, periodStart])
}
model EmailToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // "signup" | "delete"
usedAt DateTime?
tokenHash String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId, type])
@@index([userId, type, expiresAt])
@@index([tokenHash])
}

View File

@@ -14,6 +14,7 @@ const Env = z.object({
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1),
CORS_ORIGIN: z.string().optional(),
CORS_ORIGINS: z.string().optional(),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
JWT_SECRET: z.string().min(32),
@@ -22,6 +23,22 @@ const Env = z.object({
AUTH_DISABLED: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
UPDATE_NOTICE_BODY: z
.string()
.min(1)
.default("We shipped improvements and fixes. Please review the latest changes."),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_REQUIRE_TLS: BoolFromEnv.default(true),
SMTP_TLS_REJECT_UNAUTHORIZED: BoolFromEnv.default(true),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
EMAIL_FROM: z.string().min(1).default("SkyMoney <noreply@skymoneybudget.com>"),
EMAIL_BOUNCE_FROM: z.string().optional(),
EMAIL_REPLY_TO: z.string().optional(),
});
const rawEnv = {
@@ -30,6 +47,7 @@ const rawEnv = {
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
CORS_ORIGIN: process.env.CORS_ORIGIN,
CORS_ORIGINS: process.env.CORS_ORIGINS,
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
@@ -37,13 +55,27 @@ const rawEnv = {
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
AUTH_DISABLED: process.env.AUTH_DISABLED,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
APP_ORIGIN: process.env.APP_ORIGIN,
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
UPDATE_NOTICE_BODY: process.env.UPDATE_NOTICE_BODY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_REQUIRE_TLS: process.env.SMTP_REQUIRE_TLS,
SMTP_TLS_REJECT_UNAUTHORIZED: process.env.SMTP_TLS_REJECT_UNAUTHORIZED,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_BOUNCE_FROM: process.env.EMAIL_BOUNCE_FROM,
EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO,
};
const parsed = Env.parse(rawEnv);
if (parsed.NODE_ENV === "production") {
if (!parsed.CORS_ORIGIN) {
throw new Error("CORS_ORIGIN must be set in production.");
if (!parsed.CORS_ORIGIN && !parsed.CORS_ORIGINS) {
throw new Error("CORS_ORIGIN or CORS_ORIGINS must be set in production.");
}
if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) {
throw new Error("AUTH_DISABLED cannot be enabled in production.");
@@ -57,6 +89,9 @@ if (parsed.NODE_ENV === "production") {
if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) {
throw new Error("COOKIE_SECRET must be set to a strong value in production.");
}
if (!parsed.APP_ORIGIN) {
throw new Error("APP_ORIGIN must be set in production.");
}
}
export const env = parsed;

View File

@@ -4,7 +4,8 @@ import rateLimit from "@fastify/rate-limit";
import fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2";
import { randomUUID } from "node:crypto";
import { randomUUID, createHash, randomInt } from "node:crypto";
import nodemailer from "nodemailer";
import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client";
import { z } from "zod";
@@ -14,7 +15,14 @@ import { rolloverFixedPlans } from "./jobs/rollover.js";
export type AppConfig = typeof env;
const openPaths = new Set(["/health", "/health/db", "/auth/login", "/auth/register"]);
const openPaths = new Set([
"/health",
"/health/db",
"/auth/login",
"/auth/register",
"/auth/verify",
"/auth/verify/resend",
]);
const mutationRateLimit = {
config: {
rateLimit: {
@@ -92,6 +100,138 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
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 normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
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: true,
tls: {
rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED,
},
auth: config.SMTP_USER
? {
user: config.SMTP_USER,
pass: config.SMTP_PASS,
}
: undefined,
logger: true,
debug: true,
})
: 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) {
// Dev fallback: log the email for manual copy
app.log.info({ to, subject }, "[email] mailer disabled; logged email content");
console.log("[email]", { to, subject, text: finalText });
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");
}
async function issueEmailToken(
userId: string,
type: "signup" | "delete",
ttlMs: number
) {
const code = 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 };
}
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
await app.prisma.emailToken.deleteMany({
where: type ? { userId, type } : { userId },
});
}
/**
* Calculate the next due date based on frequency for rollover
*/
@@ -365,19 +505,24 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
});
}
const configuredOrigins = (config.CORS_ORIGINS || config.CORS_ORIGIN || "")
.split(",")
.map((s) => normalizeOrigin(s.trim()))
.filter(Boolean);
await app.register(cors, {
origin: (() => {
if (!config.CORS_ORIGIN) return true;
const allow = config.CORS_ORIGIN.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (origin, cb) => {
if (!origin) return cb(null, true);
cb(null, allow.includes(origin));
};
})(),
credentials: true,
});
origin: (() => {
// Keep local/dev friction-free.
if (config.NODE_ENV !== "production") return true;
if (configuredOrigins.length === 0) 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,
@@ -398,6 +543,17 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
},
});
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);
@@ -456,7 +612,7 @@ app.decorate("ensureUser", async (userId: string) => {
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
return;
}
if (path === "/auth/login" || path === "/auth/register") {
if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") {
return;
}
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
@@ -471,6 +627,11 @@ const AuthBody = z.object({
password: z.string().min(8),
});
const VerifyBody = z.object({
email: z.string().email(),
code: z.string().min(4),
});
const AllocationOverrideSchema = z.object({
type: z.enum(["fixed", "variable"]),
id: z.string().min(1),
@@ -507,23 +668,22 @@ app.post(
email: normalizedEmail,
passwordHash: hash,
displayName: email.split("@")[0] || null,
emailVerified: false,
},
});
if (config.SEED_DEFAULT_BUDGET) {
await seedDefaultBudget(app.prisma, user.id);
}
const token = await reply.jwtSign({ sub: user.id });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds
reply.setCookie("session", token, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge,
...(cookieDomain ? { domain: cookieDomain } : {}),
await clearEmailTokens(user.id, "signup");
const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS);
const origin = normalizeOrigin(config.APP_ORIGIN);
await sendEmail({
to: normalizedEmail,
subject: "Verify your SkyMoney account",
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
ensureCsrfCookie(reply);
return { ok: true };
return { ok: true, needsVerification: true };
});
app.post(
@@ -546,6 +706,9 @@ app.post(
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
if (!user.emailVerified) {
return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" });
}
await app.ensureUser(user.id);
const token = await reply.jwtSign({ sub: user.id });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds
@@ -572,6 +735,142 @@ app.post("/auth/logout", async (_req, reply) => {
return { ok: true };
});
app.post("/auth/verify", async (req, reply) => {
const parsed = VerifyBody.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
const tokenHash = hashToken(parsed.data.code.trim());
const token = await app.prisma.emailToken.findFirst({
where: { userId: user.id, type: "signup", tokenHash },
});
if (!token) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (token.expiresAt < new Date()) {
await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } });
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
}
await app.prisma.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
await clearEmailTokens(user.id, "signup");
const jwt = await reply.jwtSign({ sub: user.id });
const maxAge = config.SESSION_TIMEOUT_MINUTES * 60;
reply.setCookie("session", jwt, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
ensureCsrfCookie(reply);
return { ok: true };
});
app.post("/auth/verify/resend", async (req, reply) => {
const parsed = z.object({ email: z.string().email() }).safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) return reply.code(200).send({ ok: true });
if (user.emailVerified) return { ok: true, alreadyVerified: true };
await clearEmailTokens(user.id, "signup");
const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS);
const origin = normalizeOrigin(config.APP_ORIGIN);
await sendEmail({
to: user.email,
subject: "Verify your SkyMoney account",
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
return { ok: true };
});
app.post("/account/delete-request", async (req, reply) => {
const Body = z.object({
password: z.string().min(1),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { id: true, email: true, passwordHash: true },
});
if (!user?.passwordHash) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const valid = await argon2.verify(user.passwordHash, parsed.data.password);
if (!valid) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
await clearEmailTokens(user.id, "delete");
const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS);
await sendEmail({
to: user.email,
subject: "Confirm deletion of your SkyMoney account",
text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`,
html: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
});
return { ok: true };
});
app.post("/account/confirm-delete", async (req, reply) => {
const Body = z.object({
email: z.string().email(),
code: z.string().min(4),
password: z.string().min(1),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" });
}
const normalizedEmail = parsed.data.email.toLowerCase();
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (!user.passwordHash) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
if (!passwordOk) {
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
}
const tokenHash = hashToken(parsed.data.code.trim());
const token = await app.prisma.emailToken.findFirst({
where: { userId: user.id, type: "delete", tokenHash },
});
if (!token) {
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
}
if (token.expiresAt < new Date()) {
await clearEmailTokens(user.id, "delete");
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
}
await clearEmailTokens(user.id, "delete");
await app.prisma.user.delete({ where: { id: user.id } });
reply.clearCookie("session", {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true };
});
app.post("/auth/refresh", async (req, reply) => {
// Generate a new token to extend the session
const userId = req.userId;
@@ -595,16 +894,55 @@ app.get("/auth/session", async (req, reply) => {
}
const user = await app.prisma.user.findUnique({
where: { id: req.userId },
select: { email: true, displayName: true },
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,
};
});
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),