File size: 3,312 Bytes
5569a86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import crypto from 'crypto';
import { getDb } from '../db/index.js';
import { hashPassword, verifyPassword } from '../lib/password.js';

// Dashboard authentication: email + password accounts with opaque session
// tokens. Distinct from the unified API key, which authenticates the /v1 proxy
// for apps — this gates the /api/* admin surface for the human operator (#35).

const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

export interface SessionUser {
  userId: number;
  email: string;
}

function sha256(s: string): string {
  return crypto.createHash('sha256').update(s).digest('hex');
}

function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

export async function userCount(): Promise<number> {
  const row = await getDb().get<{ c: number }>('SELECT COUNT(*) AS c FROM users');
  return row?.c ?? 0;
}

/** Create a user. Throws { code: 'email_taken' } if the email already exists. */
export async function createUser(email: string, password: string): Promise<SessionUser> {
  const db = getDb();
  const normalized = normalizeEmail(email);
  const existing = await db.get('SELECT id FROM users WHERE email = ?', [normalized]);
  if (existing) {
    const err = new Error('An account with that email already exists') as any;
    err.code = 'email_taken';
    throw err;
  }
  const result = await db.run('INSERT INTO users (email, password_hash) VALUES (?, ?)',
    [normalized, hashPassword(password)]);
  return { userId: Number(result.lastInsertRowid), email: normalized };
}

/** Verify credentials. Returns the user on success, null on failure. */
export async function verifyCredentials(email: string, password: string): Promise<SessionUser | null> {
  const db = getDb();
  const row = await db.get<{ id: number; email: string; password_hash: string }>(
    'SELECT id, email, password_hash FROM users WHERE email = ?',
    [normalizeEmail(email)],
  );
  if (!row) return null;
  if (!verifyPassword(password, row.password_hash)) return null;
  return { userId: row.id, email: row.email };
}

/** Mint a session and return the raw token (only the hash is persisted). */
export async function createSession(userId: number): Promise<string> {
  const token = crypto.randomBytes(32).toString('hex');
  await getDb().run('INSERT INTO sessions (token_hash, user_id, expires_at_ms) VALUES (?, ?, ?)',
    [sha256(token), userId, Date.now() + SESSION_TTL_MS]);
  return token;
}

/** Resolve a session token to its user, or null if missing/expired. */
export async function validateSession(token: string | undefined | null): Promise<SessionUser | null> {
  if (!token) return null;
  const db = getDb();
  const row = await db.get<{ user_id: number; expires_at_ms: number; email: string }>(`
    SELECT s.user_id, s.expires_at_ms, u.email
    FROM sessions s JOIN users u ON u.id = s.user_id
    WHERE s.token_hash = ?
  `, [sha256(token)]);
  if (!row) return null;
  if (row.expires_at_ms < Date.now()) {
    await db.run('DELETE FROM sessions WHERE token_hash = ?', [sha256(token)]);
    return null;
  }
  return { userId: row.user_id, email: row.email };
}

export async function deleteSession(token: string | undefined | null): Promise<void> {
  if (!token) return;
  await getDb().run('DELETE FROM sessions WHERE token_hash = ?', [sha256(token)]);
}