From 493746b14c1251a45b061d2e3edd9160c929d2b9 Mon Sep 17 00:00:00 2001 From: benj Date: Fri, 10 Apr 2026 11:13:34 +0800 Subject: a basic ui and landing web interface for tidyindex.com --- web/ui/src/lib/server/auth.ts | 153 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 web/ui/src/lib/server/auth.ts (limited to 'web/ui/src/lib/server/auth.ts') diff --git a/web/ui/src/lib/server/auth.ts b/web/ui/src/lib/server/auth.ts new file mode 100644 index 0000000..6f202d3 --- /dev/null +++ b/web/ui/src/lib/server/auth.ts @@ -0,0 +1,153 @@ +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 }; +} -- cgit v1.2.3