spb/chat-spboucher Public
Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai
TypeScript 78.8%
CSS 15.1%
JavaScript 4.9%
Shell 1.2%
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";6import crypto from "node:crypto";7import { getDb } from "@/lib/db/database";89export const SESSION_COOKIE = "spb_session";10const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days11const LOGIN_WINDOW_MS = 1000 * 60 * 15;12const MAX_ATTEMPTS_PER_WINDOW = 8;1314// argon2id with sane defaults for a single-user server15const ARGON2_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 2 };1617export interface SessionInfo {18 userId: string;19 username: string;20 sessionId: string;21}2223function sha256(input: string): string {24 return crypto.createHash("sha256").update(input).digest("hex");25}2627export async function hashPassword(password: string): Promise<string> {28 return argon2Hash(password, ARGON2_OPTS);29}3031export function loginRateLimited(ip: string): boolean {32 const db = getDb();33 const since = Date.now() - LOGIN_WINDOW_MS;34 const row = db35 .prepare(36 "SELECT COUNT(*) AS n FROM login_attempts WHERE ip = ? AND created_at > ? AND success = 0"37 )38 .get(ip, since) as { n: number };39 return row.n >= MAX_ATTEMPTS_PER_WINDOW;40}4142export function recordLoginAttempt(ip: string, success: boolean): void {43 const db = getDb();44 db.prepare("INSERT INTO login_attempts (ip, success, created_at) VALUES (?, ?, ?)").run(45 ip,46 success ? 1 : 0,47 Date.now()48 );49 // opportunistic pruning50 db.prepare("DELETE FROM login_attempts WHERE created_at < ?").run(Date.now() - LOGIN_WINDOW_MS * 8);51}5253export async function verifyCredentials(54 username: string,55 password: string56): Promise<{ id: string; username: string } | null> {57 const db = getDb();58 const user = db59 .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")60 .get(username) as { id: string; username: string; password_hash: string } | undefined;61 if (!user) {62 // constant-ish time: still run a hash verification against a dummy63 await argon2Verify(64 "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",65 password66 ).catch(() => false);67 return null;68 }69 const ok = await argon2Verify(user.password_hash, password).catch(() => false);70 return ok ? { id: user.id, username: user.username } : null;71}7273export function createSession(userId: string, userAgent?: string, ip?: string): string {74 const db = getDb();75 const rawToken = crypto.randomBytes(32).toString("base64url");76 const now = Date.now();77 db.prepare(78 `INSERT INTO sessions (id, user_id, created_at, expires_at, last_seen_at, user_agent, ip)79 VALUES (?, ?, ?, ?, ?, ?, ?)`80 ).run(sha256(rawToken), userId, now, now + SESSION_TTL_MS, now, userAgent ?? null, ip ?? null);81 db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(now);82 return rawToken;83}8485export function getSession(rawToken: string | undefined): SessionInfo | null {86 if (!rawToken) return null;87 const db = getDb();88 const now = Date.now();89 const row = db90 .prepare(91 `SELECT s.id AS session_id, u.id AS user_id, u.username92 FROM sessions s JOIN users u ON u.id = s.user_id93 WHERE s.id = ? AND s.expires_at > ?`94 )95 .get(sha256(rawToken), now) as96 | { session_id: string; user_id: string; username: string }97 | undefined;98 if (!row) return null;99 db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").run(100 now,101 now + SESSION_TTL_MS,102 row.session_id103 );104 return { userId: row.user_id, username: row.username, sessionId: row.session_id };105}106107export function destroySession(rawToken: string | undefined): void {108 if (!rawToken) return;109 getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sha256(rawToken));110}111112export function sessionCookieHeader(rawToken: string, maxAgeSeconds: number): string {113 return [114 `${SESSION_COOKIE}=${rawToken}`,115 "Path=/",116 "HttpOnly",117 "Secure",118 "SameSite=Lax",119 `Max-Age=${maxAgeSeconds}`,120 ].join("; ");121}122