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>
58 lines
2.2 KiB
TypeScript
58 lines
2.2 KiB
TypeScript
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}.`);
|
||
}
|