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

135
server/src/auth/sessions.ts Normal file
View File

@@ -0,0 +1,135 @@
import { and, eq, isNull, ne } from 'drizzle-orm';
import type { Context } from 'hono';
import { deleteCookie, setCookie } from 'hono/cookie';
import { createHash, randomBytes } from 'node:crypto';
import type { AppContext } from '../context';
import { sessions, users, type SessionRow, type UserRow } from '../db/schema';
import { requestProto } from '../http';
export const SESSION_COOKIE = 'sl_session';
const DAY_MS = 86_400_000;
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
/** "iPhone · Safari", "Mac · Chrome" and so on, for the devices list. */
export function deviceLabel(userAgent: string | null | undefined): string {
const ua = userAgent ?? '';
const device = /iPhone/.test(ua)
? 'iPhone'
: /iPad/.test(ua)
? 'iPad'
: /Android/.test(ua)
? 'Android'
: /Macintosh|Mac OS X/.test(ua)
? 'Mac'
: /Windows/.test(ua)
? 'Windows PC'
: /CrOS/.test(ua)
? 'Chromebook'
: /Linux/.test(ua)
? 'Linux'
: 'Unknown device';
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\/|CriOS\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: null;
return browser ? `${device} · ${browser}` : device;
}
export function createSession(
ctx: AppContext,
userId: number,
meta: { userAgent?: string | null; ip?: string | null },
now = new Date(),
): { token: string; session: SessionRow } {
const token = randomBytes(32).toString('base64url');
const session = ctx.db
.insert(sessions)
.values({
id: hashToken(token),
userId,
kind: 'browser',
deviceLabel: deviceLabel(meta.userAgent),
userAgent: meta.userAgent ?? null,
ip: meta.ip ?? null,
createdAt: now.toISOString(),
lastSeenAt: now.toISOString(),
expiresAt: new Date(now.getTime() + ctx.config.sessionDays * DAY_MS).toISOString(),
})
.returning()
.get();
return { token, session };
}
export function loadSession(ctx: AppContext, token: string, now = new Date()): { session: SessionRow; user: UserRow } | null {
const row = ctx.db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, hashToken(token)), isNull(users.deletedAt)))
.get();
if (!row) return null;
if (Date.parse(row.session.expiresAt) <= now.getTime()) {
ctx.db.delete(sessions).where(eq(sessions.id, row.session.id)).run();
return null;
}
return row;
}
/**
* Keep a session alive. The expiry slides forward (at most once a day, to
* save writes) so a device in regular use never signs out. Returns true when
* the cookie should be re-sent with a fresh lifetime.
*/
export function touchSession(ctx: AppContext, session: SessionRow, ip: string, now = new Date()): boolean {
const fullLife = ctx.config.sessionDays * DAY_MS;
const remaining = Date.parse(session.expiresAt) - now.getTime();
if (remaining < fullLife - DAY_MS) {
ctx.db
.update(sessions)
.set({
expiresAt: new Date(now.getTime() + fullLife).toISOString(),
lastSeenAt: now.toISOString(),
ip,
})
.where(eq(sessions.id, session.id))
.run();
return true;
}
if (now.getTime() - Date.parse(session.lastSeenAt) > 5 * 60_000) {
ctx.db.update(sessions).set({ lastSeenAt: now.toISOString(), ip }).where(eq(sessions.id, session.id)).run();
}
return false;
}
export function revokeSession(ctx: AppContext, sessionId: string): void {
ctx.db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
/** Sign a person out everywhere, optionally keeping the device they're on. */
export function revokeUserSessions(ctx: AppContext, userId: number, exceptSessionId?: string): void {
const where = exceptSessionId
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
: eq(sessions.userId, userId);
ctx.db.delete(sessions).where(where).run();
}
export function setSessionCookie(c: Context, ctx: AppContext, token: string): void {
setCookie(c, SESSION_COOKIE, token, {
httpOnly: true,
secure: requestProto(c, ctx) === 'https',
sameSite: 'Lax',
path: '/',
maxAge: ctx.config.sessionDays * 86_400,
});
}
export function clearSessionCookie(c: Context): void {
deleteCookie(c, SESSION_COOKIE, { path: '/' });
}