Milestones 0 and 1: foundation and money in (20261007.10)

Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

57
server/src/auth/guards.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Context, MiddlewareHandler } from 'hono';
import { getCookie } from 'hono/cookie';
import type { AppContext } from '../context';
import type { UserRow } from '../db/schema';
import { clientIp, fail, hostnameOf, isLocalHost, requestHost, requestProto, type Env } from '../http';
import { clearSessionCookie, loadSession, SESSION_COOKIE, setSessionCookie, touchSession } from './sessions';
/** Attach the signed-in person (if any) to every API request, and keep their session fresh. */
export function sessionMiddleware(ctx: AppContext): MiddlewareHandler<Env> {
return async (c, next) => {
c.set('user', null);
c.set('session', null);
const token = getCookie(c, SESSION_COOKIE);
if (token) {
const found = loadSession(ctx, token);
if (found) {
c.set('user', found.user);
c.set('session', found.session);
if (touchSession(ctx, found.session, clientIp(c, ctx))) setSessionCookie(c, ctx, token);
} else {
clearSessionCookie(c);
}
}
await next();
};
}
export function requireUser(c: Context<Env>): UserRow {
const user = c.get('user');
if (!user) fail(401, 'signed_out', 'Please sign in to continue.');
return user;
}
/** Changing household data needs Full access; Read-only people can look but not touch. */
export function requireWrite(c: Context<Env>): UserRow {
const user = requireUser(c);
if (user.access !== 'full') fail(403, 'read_only', 'Your access is read-only, so this change isn’t available.');
return user;
}
export function requireAdmin(c: Context<Env>): UserRow {
const user = requireUser(c);
if (!user.isAdmin) fail(403, 'admin_only', 'Only an admin can do that.');
return user;
}
/**
* Refuse password sign-in over plain HTTP from anywhere but this machine, so
* passwords and cookies only ever travel encrypted.
*/
export function assertSecureSignIn(c: Context<Env>, ctx: AppContext): void {
if (ctx.config.allowInsecureSignin) return;
if (requestProto(c, ctx) === 'https') return;
if (isLocalHost(hostnameOf(requestHost(c, ctx)))) return;
const where = ctx.config.publicUrl ? `: ${ctx.config.publicUrl.origin}` : '';
fail(400, 'insecure_connection', `For your security, open Spendlight at its secure (https) address to sign in${where}.`);
}