| 1 | import type { UserRow } from "../types"; |
| 2 | import { generateToken, hashToken } from "../auth/crypto"; |
| 3 | import { SESSION_TTL_MS } from "../config"; |
| 4 | |
| 5 | export interface NewSession { |
| 6 | kind?: "web" | "cli"; |
| 7 | userAgent?: string; |
| 8 | ttlMs?: number; |
| 9 | } |
| 10 | |
| 11 | export class SessionRepo { |
| 12 | constructor( |
| 13 | private readonly db: D1Database, |
| 14 | private readonly pepper: string, |
| 15 | ) {} |
| 16 | |
| 17 | // Persists sha256(pepper:token) and returns the raw token for the cookie. |
| 18 | async create(userId: number, opts: NewSession = {}): Promise<string> { |
| 19 | const token = generateToken(); |
| 20 | const tokenHash = await hashToken(this.pepper, token); |
| 21 | const now = new Date(); |
| 22 | const expires = new Date(now.getTime() + (opts.ttlMs ?? SESSION_TTL_MS)); |
| 23 | await this.db |
| 24 | .prepare( |
| 25 | `INSERT INTO sessions (token_hash, user_id, kind, user_agent, created_at, last_seen_at, expires_at) |
| 26 | VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6)`, |
| 27 | ) |
| 28 | .bind(tokenHash, userId, opts.kind ?? "web", (opts.userAgent ?? "").slice(0, 256), now.toISOString(), expires.toISOString()) |
| 29 | .run(); |
| 30 | return token; |
| 31 | } |
| 32 | |
| 33 | // Resolves a cookie token to its active user, pruning the row if expired. |
| 34 | async resolve(token: string): Promise<UserRow | null> { |
| 35 | const tokenHash = await hashToken(this.pepper, token); |
| 36 | const row = await this.db |
| 37 | .prepare( |
| 38 | `SELECT u.*, s.expires_at AS s_expires |
| 39 | FROM sessions s JOIN users u ON u.id = s.user_id |
| 40 | WHERE s.token_hash = ?1`, |
| 41 | ) |
| 42 | .bind(tokenHash) |
| 43 | .first<UserRow & { s_expires: string }>(); |
| 44 | if (!row) return null; |
| 45 | if (row.s_expires <= new Date().toISOString()) { |
| 46 | await this.deleteByHash(tokenHash); |
| 47 | return null; |
| 48 | } |
| 49 | if (row.status !== "active") return null; |
| 50 | const { s_expires: _ignored, ...user } = row; |
| 51 | return user; |
| 52 | } |
| 53 | |
| 54 | async deleteByToken(token: string): Promise<void> { |
| 55 | await this.deleteByHash(await hashToken(this.pepper, token)); |
| 56 | } |
| 57 | |
| 58 | async deleteAllForUser(userId: number): Promise<void> { |
| 59 | await this.db.prepare("DELETE FROM sessions WHERE user_id = ?1").bind(userId).run(); |
| 60 | } |
| 61 | |
| 62 | private async deleteByHash(tokenHash: string): Promise<void> { |
| 63 | await this.db.prepare("DELETE FROM sessions WHERE token_hash = ?1").bind(tokenHash).run(); |
| 64 | } |
| 65 | } |
| 66 |