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

3
.env
View File

@@ -50,5 +50,6 @@ EXPECTED_BACKUP_DB_NAME=skymoney
PROD_DB_VOLUME_NAME=skymoney_pgdata PROD_DB_VOLUME_NAME=skymoney_pgdata
ALLOW_EMPTY_PROD_VOLUME=0 ALLOW_EMPTY_PROD_VOLUME=0
EMAIL_VERIFY_DEV_CODE_EXPOSE=false EMAIL_VERIFY_DEV_CODE_EXPOSE=false
BREAK_GLASS_VERIFY_ENABLED=false UNDER_CONSTRUCTION_ENABLED=true
BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ

View File

@@ -41,6 +41,7 @@ COOKIE_SECRET=replace-with-32+-chars
EMAIL_VERIFY_DEV_CODE_EXPOSE=false EMAIL_VERIFY_DEV_CODE_EXPOSE=false
BREAK_GLASS_VERIFY_ENABLED=false BREAK_GLASS_VERIFY_ENABLED=false
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
UNDER_CONSTRUCTION_ENABLED=false
AUTH_MAX_FAILED_ATTEMPTS=5 AUTH_MAX_FAILED_ATTEMPTS=5
AUTH_LOCKOUT_WINDOW_MS=900000 AUTH_LOCKOUT_WINDOW_MS=900000
PASSWORD_RESET_TTL_MINUTES=30 PASSWORD_RESET_TTL_MINUTES=30

View File

@@ -28,4 +28,5 @@ SEED_DEFAULT_BUDGET=true
BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
EMAIL_VERIFY_DEV_CODE_EXPOSE=true EMAIL_VERIFY_DEV_CODE_EXPOSE=true
UNDER_CONSTRUCTION_ENABLED=false

View File

@@ -26,6 +26,7 @@ COOKIE_SECRET=replace-with-32+-chars
EMAIL_VERIFY_DEV_CODE_EXPOSE=true EMAIL_VERIFY_DEV_CODE_EXPOSE=true
BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_ENABLED=true
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
UNDER_CONSTRUCTION_ENABLED=false
AUTH_DISABLED=false AUTH_DISABLED=false
ALLOW_INSECURE_AUTH_FOR_DEV=false ALLOW_INSECURE_AUTH_FOR_DEV=false

View File

@@ -52,6 +52,7 @@ const Env = z.object({
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false), EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false), BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(), BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
UNDER_CONSTRUCTION_ENABLED: BoolFromEnv.default(false),
AUTH_DISABLED: BoolFromEnv.optional().default(false), AUTH_DISABLED: BoolFromEnv.optional().default(false),
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false), ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
@@ -96,6 +97,7 @@ const rawEnv = {
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE, 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_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE, BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
UNDER_CONSTRUCTION_ENABLED: process.env.UNDER_CONSTRUCTION_ENABLED,
AUTH_DISABLED: process.env.AUTH_DISABLED, AUTH_DISABLED: process.env.AUTH_DISABLED,
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV, ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, 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 fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt"; import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2"; 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 nodemailer from "nodemailer";
import { env } from "./env.js"; import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client"; import { PrismaClient, Prisma } from "@prisma/client";
@@ -17,6 +17,9 @@ export type AppConfig = typeof env;
const openPaths = new Set([ const openPaths = new Set([
"/health", "/health",
"/site-access/status",
"/site-access/unlock",
"/site-access/lock",
"/auth/login", "/auth/login",
"/auth/register", "/auth/register",
"/auth/verify", "/auth/verify",
@@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => {
}; };
const CSRF_COOKIE = "csrf"; const CSRF_COOKIE = "csrf";
const CSRF_HEADER = "x-csrf-token"; 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 } = { const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id, type: argon2.argon2id,
memoryCost: 19_456, memoryCost: 19_456,
@@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
const isProd = config.NODE_ENV === "production"; const isProd = config.NODE_ENV === "production";
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE; const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
const cookieDomain = config.COOKIE_DOMAIN || undefined; 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({ const app = Fastify({
logger: true, logger: true,
@@ -310,6 +327,13 @@ function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex"); 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( async function issueEmailToken(
userId: string, userId: string,
type: EmailTokenType, type: EmailTokenType,
@@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => {
app.addHook("onRequest", async (req, reply) => { app.addHook("onRequest", async (req, reply) => {
reply.header("x-request-id", String(req.id ?? "")); reply.header("x-request-id", String(req.id ?? ""));
const path = pathOf(req.url ?? ""); 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 // Open paths don't require authentication
if (openPaths.has(path)) { if (openPaths.has(path)) {
@@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => {
return; return;
} }
if ( if (
path === "/site-access/unlock" ||
path === "/site-access/lock" ||
path === "/auth/login" || path === "/auth/login" ||
path === "/auth/register" || path === "/auth/register" ||
path === "/auth/verify" || 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) => { app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
const Body = z.object({ const Body = z.object({
version: z.coerce.number().int().nonnegative().optional(), 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;
}

View File

@@ -0,0 +1,67 @@
# API Refactor Lightweight Plan
## Goal
Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves.
Current state (2026-03-15):
- `server.ts` has ~4.8k lines and 50 endpoint registrations.
- Duplicate endpoint signatures also exist in `api/src/routes/*` but are not currently registered.
## Refactor Guardrails
1. Keep route behavior identical while moving code.
2. Move one domain at a time; do not mix domains in one PR.
3. Do not redesign architecture (no repositories/DI/container work).
4. Extract only duplicated logic into shared helpers/services.
5. Preserve global hooks and plugin registration in `server.ts` until final cleanup.
6. Keep response shapes stable (`ok`, `code`, `message`, etc.) during moves.
7. Require tests to pass after each move phase.
## Canonical Source Rule
`server.ts` is the canonical route logic right now.
When moving a domain:
1. Copy current logic from `server.ts` into the domain route module.
2. Register the module.
3. Remove the original block from `server.ts`.
4. Confirm no duplicate route registrations remain.
## Shared Helpers (Phase 0)
Create these helpers first to keep endpoint moves lightweight:
1. `api/src/services/user-context.ts`
- `getUserTimezone(...)`
- Removes repeated user timezone lookup logic.
2. `api/src/services/category-shares.ts`
- `computePercentShares(...)`
- `computeWithdrawShares(...)`
- `computeOverdraftShares(...)`
- `computeDepositShares(...)`
- Centralizes repeated category-share math.
3. `api/src/services/budget-session.ts`
- `getLatestBudgetSession(...)`
- `ensureBudgetSession(...)`
- `ensureBudgetSessionAvailableSynced(...)`
- Prevents session/value drift bugs.
4. `api/src/services/api-errors.ts`
- Standard typed error object and response body builder.
- Keeps endpoint error handling consistent.
## Move Order (Incremental)
1. Low-risk endpoints: `health`, `session`, `me`, `user/config`.
2. `auth` + `account` endpoints.
3. `variable-categories` endpoints.
4. `transactions` endpoints.
5. `fixed-plans` endpoints.
6. `income`, `budget`, `payday` endpoints.
7. `dashboard` + `crisis-status`.
8. `admin` endpoints.
## Definition of Done per Phase
1. Endpoints compile and register once.
2. Existing tests pass.
3. No route path/method changes.
4. No response contract changes.
5. `server.ts` net line count decreases.

19
web/src/api/siteAccess.ts Normal file
View File

@@ -0,0 +1,19 @@
import { apiGet, apiPost } from "./http";
export type SiteAccessStatus = {
ok: boolean;
enabled: boolean;
unlocked: boolean;
};
export async function getSiteAccessStatus(): Promise<SiteAccessStatus> {
return apiGet<SiteAccessStatus>("/site-access/status");
}
export async function unlockSiteAccess(code: string): Promise<SiteAccessStatus> {
return apiPost<SiteAccessStatus>("/site-access/unlock", { code });
}
export async function lockSiteAccess(): Promise<SiteAccessStatus> {
return apiPost<SiteAccessStatus>("/site-access/lock");
}

View File

@@ -1,5 +1,6 @@
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { getSiteAccessStatus } from "../api/siteAccess";
const STORAGE_KEY = "skymoney_beta_access"; const STORAGE_KEY = "skymoney_beta_access";
@@ -10,16 +11,43 @@ type Props = {
export function BetaGate({ children }: Props) { export function BetaGate({ children }: Props) {
const location = useLocation(); const location = useLocation();
const [hasAccess, setHasAccess] = useState<boolean | null>(null); const [hasAccess, setHasAccess] = useState<boolean | null>(null);
const [isEnabled, setIsEnabled] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
setHasAccess(localStorage.getItem(STORAGE_KEY) === "true"); let cancelled = false;
(async () => {
try {
const status = await getSiteAccessStatus();
const unlocked = !!status.unlocked;
if (!cancelled) {
setIsEnabled(!!status.enabled);
setHasAccess(!status.enabled || unlocked);
}
if (status.enabled && unlocked) {
localStorage.setItem(STORAGE_KEY, "true");
}
if (status.enabled && !unlocked) {
localStorage.removeItem(STORAGE_KEY);
}
} catch {
// Fallback for temporary API/network issues.
const localFallback = localStorage.getItem(STORAGE_KEY) === "true";
if (!cancelled) {
setIsEnabled(true);
setHasAccess(localFallback);
}
}
})();
return () => {
cancelled = true;
};
}, []); }, []);
if (location.pathname === "/beta") { if (location.pathname === "/beta") {
return <>{children}</>; return <>{children}</>;
} }
if (hasAccess === null) { if (hasAccess === null || isEnabled === null) {
return ( return (
<div className="flex min-h-[40vh] items-center justify-center text-sm muted"> <div className="flex min-h-[40vh] items-center justify-center text-sm muted">
Checking access Checking access
@@ -27,7 +55,7 @@ export function BetaGate({ children }: Props) {
); );
} }
if (!hasAccess) { if (isEnabled && !hasAccess) {
return <Navigate to="/beta" replace />; return <Navigate to="/beta" replace />;
} }

View File

@@ -1,26 +1,65 @@
import { useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { betaAccessStorageKey } from "../components/BetaGate"; import { betaAccessStorageKey } from "../components/BetaGate";
import { getSiteAccessStatus, lockSiteAccess, unlockSiteAccess } from "../api/siteAccess";
const ACCESS_CODE = "jodygavemeaccess123";
export default function BetaAccessPage() { export default function BetaAccessPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [enabled, setEnabled] = useState<boolean | null>(null);
const isValid = useMemo(() => code.trim() === ACCESS_CODE, [code]); const [isUnlocked, setIsUnlocked] = useState(
const isUnlocked = useMemo( localStorage.getItem(betaAccessStorageKey) === "true"
() => localStorage.getItem(betaAccessStorageKey) === "true",
[]
); );
const handleSubmit = (event: React.FormEvent) => { useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await getSiteAccessStatus();
if (!cancelled) setEnabled(!!status.enabled);
} catch {
if (!cancelled) setEnabled(true);
}
})();
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setTouched(true); setTouched(true);
if (!isValid) return; setError("");
localStorage.setItem(betaAccessStorageKey, "true"); if (!code.trim()) return;
navigate("/login", { replace: true }); setLoading(true);
try {
const result = await unlockSiteAccess(code.trim());
if (!result.unlocked) {
setError("That code doesnt match. Please try again.");
return;
}
localStorage.setItem(betaAccessStorageKey, "true");
setIsUnlocked(true);
navigate("/login", { replace: true });
} catch (err: any) {
setError(err?.message ?? "Unable to unlock access right now.");
} finally {
setLoading(false);
}
};
const handleClearAccess = async () => {
localStorage.removeItem(betaAccessStorageKey);
setIsUnlocked(false);
try {
await lockSiteAccess();
} catch {
// noop
}
}; };
return ( return (
@@ -28,14 +67,15 @@ export default function BetaAccessPage() {
<div className="w-full max-w-xl card p-8 sm:p-10"> <div className="w-full max-w-xl card p-8 sm:p-10">
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs uppercase tracking-[0.2em] muted"> <div className="text-xs uppercase tracking-[0.2em] muted">
Private beta Under construction
</div> </div>
<h1 className="text-3xl sm:text-4xl font-semibold"> <h1 className="text-3xl sm:text-4xl font-semibold">
Welcome to SkyMoney SkyMoney maintenance mode
</h1> </h1>
<p className="muted"> <p className="muted">
This build is private. If youve been given access, enter your code Public access is temporarily paused while we ship updates. Enter
below. If not, reach out to{" "} your maintenance access code to continue testing. If you need
access, reach out to{" "}
<a <a
href="https://jodyholt.com" href="https://jodyholt.com"
target="_blank" target="_blank"
@@ -46,6 +86,11 @@ export default function BetaAccessPage() {
</a>{" "} </a>{" "}
for access. for access.
</p> </p>
{enabled === false && (
<p className="text-sm text-emerald-500">
Maintenance mode is currently disabled.
</p>
)}
</div> </div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> <form onSubmit={handleSubmit} className="mt-6 space-y-4">
@@ -58,6 +103,7 @@ export default function BetaAccessPage() {
value={code} value={code}
onChange={(event) => { onChange={(event) => {
setTouched(true); setTouched(true);
setError("");
setCode(event.target.value); setCode(event.target.value);
}} }}
placeholder="Enter your code" placeholder="Enter your code"
@@ -66,28 +112,37 @@ export default function BetaAccessPage() {
</div> </div>
</label> </label>
{touched && code.length > 0 && !isValid && ( {touched && code.length > 0 && !!error && (
<div className="text-sm text-red-500"> <div className="text-sm text-red-500">
That code doesnt match. Please try again. {error}
</div> </div>
)} )}
<button <button
type="submit" type="submit"
className="btn primary w-full" className="btn primary w-full"
disabled={!isValid} disabled={!code.trim() || loading}
> >
Continue {loading ? "Checking…" : "Continue"}
</button> </button>
{isUnlocked && ( {isUnlocked && (
<button <div className="grid gap-2 sm:grid-cols-2">
type="button" <button
className="btn w-full" type="button"
onClick={() => navigate("/login")} className="btn w-full"
> onClick={() => navigate("/login")}
I already have access >
</button> I already have access
</button>
<button
type="button"
className="btn w-full"
onClick={handleClearAccess}
>
Clear local access
</button>
</div>
)} )}
</form> </form>
</div> </div>