aboutsummaryrefslogtreecommitdiff
path: root/web/ui/src/lib/server/auth.ts
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
committerbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
commit493746b14c1251a45b061d2e3edd9160c929d2b9 (patch)
tree1607cceb94c1aac1a17a01bb5c0d71b97342e892 /web/ui/src/lib/server/auth.ts
parentc041641634650c31e03c70dcad132fd94cb08e63 (diff)
downloadtidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.gz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.bz2
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.lz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.xz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.zst
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.zip
a basic ui and landing web interface for tidyindex.com
Diffstat (limited to '')
-rw-r--r--web/ui/src/lib/server/auth.ts153
1 files changed, 153 insertions, 0 deletions
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 };
+}