added udner construction for file compaction, planning for unbloating
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
64
api/src/services/api-errors.ts
Normal file
64
api/src/services/api-errors.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
70
api/src/services/budget-session.ts
Normal file
70
api/src/services/budget-session.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
200
api/src/services/category-shares.ts
Normal file
200
api/src/services/category-shares.ts
Normal 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 })) };
|
||||
}
|
||||
19
api/src/services/user-context.ts
Normal file
19
api/src/services/user-context.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user