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:
135
server/src/auth/sessions.ts
Normal file
135
server/src/auth/sessions.ts
Normal 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: '/' });
|
||||
}
|
||||
Reference in New Issue
Block a user