added udner construction for file compaction, planning for unbloating
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s

This commit is contained in:
2026-03-15 14:44:47 -05:00
parent 512e21276c
commit ba549f6c84
14 changed files with 663 additions and 31 deletions

View File

@@ -52,6 +52,7 @@ const Env = z.object({
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
UNDER_CONSTRUCTION_ENABLED: BoolFromEnv.default(false),
AUTH_DISABLED: BoolFromEnv.optional().default(false),
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
@@ -96,6 +97,7 @@ const rawEnv = {
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
UNDER_CONSTRUCTION_ENABLED: process.env.UNDER_CONSTRUCTION_ENABLED,
AUTH_DISABLED: process.env.AUTH_DISABLED,
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,

View File

@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
import fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2";
import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto";
import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
import nodemailer from "nodemailer";
import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client";
@@ -17,6 +17,9 @@ 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",
@@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => {
};
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;
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 19_456,
@@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
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,
@@ -310,6 +327,13 @@ 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,
@@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => {
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)) {
@@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => {
return;
}
if (
path === "/site-access/unlock" ||
path === "/site-access/lock" ||
path === "/auth/login" ||
path === "/auth/register" ||
path === "/auth/verify" ||
@@ -1503,6 +1548,65 @@ app.get("/auth/session", async (req, reply) => {
};
});
app.get("/site-access/status", async (req) => {
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
return {
ok: true,
enabled: true,
unlocked: hasSiteAccessBypass(req),
};
});
app.post("/site-access/unlock", authRateLimit, async (req, reply) => {
const Body = z.object({
code: z.string().min(1).max(512),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
}
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
if (!config.BREAK_GLASS_VERIFY_ENABLED || !siteAccessExpectedToken) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
message: "Under-construction access is not configured.",
});
}
if (!safeEqual(parsed.data.code, config.BREAK_GLASS_VERIFY_CODE!)) {
return reply.code(401).send({
ok: false,
code: "INVALID_ACCESS_CODE",
message: "Invalid access code.",
});
}
reply.setCookie(SITE_ACCESS_COOKIE, siteAccessExpectedToken, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge: SITE_ACCESS_MAX_AGE_SECONDS,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true, enabled: true, unlocked: true };
});
app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => {
reply.clearCookie(SITE_ACCESS_COOKIE, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
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(),

View File

@@ -0,0 +1,64 @@
export class ApiError extends Error {
statusCode: number;
code: string;
details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
export function badRequest(code: string, message: string, details?: unknown) {
return new ApiError(400, code, message, details);
}
export function unauthorized(code: string, message: string, details?: unknown) {
return new ApiError(401, code, message, details);
}
export function forbidden(code: string, message: string, details?: unknown) {
return new ApiError(403, code, message, details);
}
export function notFound(code: string, message: string, details?: unknown) {
return new ApiError(404, code, message, details);
}
export function conflict(code: string, message: string, details?: unknown) {
return new ApiError(409, code, message, details);
}
export function toErrorBody(err: unknown, requestId: string) {
if (err instanceof ApiError) {
return {
statusCode: err.statusCode,
body: {
ok: false,
code: err.code,
message: err.message,
requestId,
...(err.details !== undefined ? { details: err.details } : {}),
},
};
}
const fallback = err as { statusCode?: number; code?: string; message?: string };
const statusCode =
typeof fallback?.statusCode === "number" ? fallback.statusCode : 500;
return {
statusCode,
body: {
ok: false,
code: fallback?.code ?? "INTERNAL",
message:
statusCode >= 500
? "Something went wrong"
: fallback?.message ?? "Bad request",
requestId,
},
};
}

View File

@@ -0,0 +1,70 @@
import type { Prisma, PrismaClient } from "@prisma/client";
type BudgetSessionAccessor =
| Pick<PrismaClient, "budgetSession">
| Pick<Prisma.TransactionClient, "budgetSession">;
function normalizeAvailableCents(value: number): bigint {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.max(0, Math.trunc(value)));
}
export async function getLatestBudgetSession(
db: BudgetSessionAccessor,
userId: string
) {
return db.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
export async function ensureBudgetSession(
db: BudgetSessionAccessor,
userId: string,
fallbackAvailableCents = 0,
now = new Date()
) {
const existing = await getLatestBudgetSession(db, userId);
if (existing) return existing;
const start = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)
);
const end = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)
);
const normalizedAvailable = normalizeAvailableCents(fallbackAvailableCents);
return db.budgetSession.create({
data: {
userId,
periodStart: start,
periodEnd: end,
totalBudgetCents: normalizedAvailable,
allocatedCents: 0n,
fundedCents: 0n,
availableCents: normalizedAvailable,
},
});
}
export async function ensureBudgetSessionAvailableSynced(
db: BudgetSessionAccessor,
userId: string,
availableCents: number
) {
const normalizedAvailable = normalizeAvailableCents(availableCents);
const session = await ensureBudgetSession(
db,
userId,
Number(normalizedAvailable)
);
if ((session.availableCents ?? 0n) === normalizedAvailable) return session;
return db.budgetSession.update({
where: { id: session.id },
data: { availableCents: normalizedAvailable },
});
}

View File

@@ -0,0 +1,200 @@
export type PercentCategory = {
id: string;
percent: number;
balanceCents: bigint | number | null;
};
type ShareRow = {
id: string;
share: number;
};
type ShareResult =
| { ok: true; shares: ShareRow[] }
| { ok: false; reason: "no_percent" | "insufficient_balances" };
function toWholeCents(value: bigint | number | null | undefined): number {
if (typeof value === "bigint") return Number(value);
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
return 0;
}
function normalizedAmountCents(amountCents: number): number {
if (!Number.isFinite(amountCents)) return 0;
return Math.max(0, Math.trunc(amountCents));
}
export function computePercentShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
balanceCents: toWholeCents(cat.balanceCents),
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, reason: "insufficient_balances" };
}
return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) };
}
export function computeWithdrawShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const working = categories.map((cat) => ({
id: cat.id,
percent: cat.percent,
balanceCents: toWholeCents(cat.balanceCents),
share: 0,
}));
let remaining = normalizedAmountCents(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, reason: "insufficient_balances" };
}
return {
ok: true,
shares: working.map((c) => ({ id: c.id, share: c.share })),
};
}
export function computeOverdraftShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, shares: shares.map(({ id, share }) => ({ id, share })) };
}
export function computeDepositShares(
categories: PercentCategory[],
amountCents: number
): ShareResult {
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
const shares = categories.map((cat) => {
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return {
id: cat.id,
share: floored,
frac: raw - floored,
};
});
let remainder =
normalizedAmountCents(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, shares: shares.map(({ id, share }) => ({ id, share })) };
}

View File

@@ -0,0 +1,19 @@
import type { Prisma, PrismaClient } from "@prisma/client";
export const DEFAULT_USER_TIMEZONE = "America/New_York";
type UserReader =
| Pick<PrismaClient, "user">
| Pick<Prisma.TransactionClient, "user">;
export async function getUserTimezone(
db: UserReader,
userId: string,
fallback: string = DEFAULT_USER_TIMEZONE
): Promise<string> {
const user = await db.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return user?.timezone ?? fallback;
}