import jwt from 'jsonwebtoken'; import { randomBytes } from 'node:crypto'; import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; import type { Cookies } from '@sveltejs/kit'; import { db, queries, newId, now, type Account } from './db'; function getJwtSecret(): string { const secret = env.JWT_SECRET; if (!secret) { throw new Error( 'JWT_SECRET env variable is required. Copy .env.example to .env ' + 'and generate one with `openssl rand -base64 48`.' ); } return secret; } const COOKIE_NAME = 'ti_sess'; const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year interface SessionPayload { sub: string; // account id } export function createSession(cookies: Cookies, accountId: string): void { const token = jwt.sign( { sub: accountId } satisfies SessionPayload, getJwtSecret(), { algorithm: 'HS256' } ); cookies.set(COOKIE_NAME, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: !dev, maxAge: COOKIE_MAX_AGE }); } export function clearSession(cookies: Cookies): void { cookies.delete(COOKIE_NAME, { path: '/' }); } export function getAccountFromCookies(cookies: Cookies): Account | null { const raw = cookies.get(COOKIE_NAME); if (!raw) return null; try { const decoded = jwt.verify(raw, getJwtSecret(), { algorithms: ['HS256'] }) as SessionPayload; if (!decoded.sub) return null; const account = queries.accountById.get(decoded.sub); return account ?? null; } catch { return null; } } // -------- account helpers -------- /** Create a brand-new anonymous account (no email). */ export function createAnonymousAccount(): Account { const id = newId(); const ts = now(); queries.insertAccount.run(id, null, 'free', ts); return { id, email: null, pending_email: null, plan: 'free', created_at: ts }; } /** Find an account by email or create a new one with that email. */ export function getOrCreateAccountByEmail(email: string): Account { const existing = queries.accountByEmail.get(email); if (existing) return existing; const id = newId(); const ts = now(); queries.insertAccount.run(id, email, 'free', ts); return { id, email, pending_email: null, plan: 'free', created_at: ts }; } // -------- magic links -------- export interface MagicLink { token: string; expires_at: number; url: string; } export function generateMagicLink( accountId: string, baseUrl: string ): MagicLink { const token = randomBytes(24).toString('base64url'); const id = newId(); const ts = now(); const expires = ts + 15 * 60 * 1000; // 15 minutes queries.insertMagicLink.run(id, accountId, token, expires, ts); const url = `${baseUrl.replace(/\/$/, '')}/auth/callback?token=${token}`; return { token, expires_at: expires, url }; } export function consumeMagicLink(token: string): Account | null { const row = queries.magicLinkByToken.get(token); if (!row) return null; if (row.used === 1) return null; if (row.expires_at < now()) return null; queries.markMagicLinkUsed.run(row.id); const account = queries.accountById.get(row.account_id); return account ?? null; } // -------- email attach / merge -------- /** * Attach an email to an existing (anonymous) account. If another account * already uses that email, merge this account into the other one — all * API keys move over, the anonymous account is deleted, and the caller * receives the surviving account id (which may be different from the * one passed in). */ export function attachEmailToAccount( accountId: string, email: string ): { accountId: string; merged: boolean } { const normalized = email.trim().toLowerCase(); if (!normalized) throw new Error('Email is empty'); const current = queries.accountById.get(accountId); if (!current) throw new Error('Account not found'); const existing = queries.accountByEmail.get(normalized); // Same account already has this email — no-op. if (existing && existing.id === current.id) { return { accountId: current.id, merged: false }; } // No other account has this email — just attach it. if (!existing) { queries.updateAccountEmail.run(normalized, current.id); return { accountId: current.id, merged: false }; } // Another account already owns the email. Merge current into existing: // move all keys over, then delete the current account. const merge = db.transaction(() => { queries.reassignKeys.run(existing.id, current.id); queries.deleteAccount.run(current.id); }); merge(); return { accountId: existing.id, merged: true }; }