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>
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
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: '/' });
|
|
}
|