import type { TokenScope } from "@sttracker/db/schema"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; import { sql } from "kysely"; import { AuthError } from "./errors.ts"; export type User = { id: string; username: string; displayName: string; role: "admin" | "member" }; const USERNAME_RE = /^[a-z0-9_]{2,32}$/; const DAY_MS = 86_400_000; const SESSION_DAYS = 30; function sha256Hex(s: string): string { return new Bun.CryptoHasher("sha256").update(s).digest("hex"); } let dummy: Promise | undefined; /** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */ function dummyHash(): Promise { return (dummy ??= Bun.password.hash("sttracker-dummy-password", { algorithm: "argon2id" })); } function randomToken(bytes = 32): string { return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true }); } function toUser(r: { id: string; username: string; display_name: string; role: "admin" | "member" }): User { return { id: r.id, username: r.username, displayName: r.display_name, role: r.role }; } export async function registerUser( db: Db, input: { username: string; password: string; displayName?: string; inviteCode?: string }, ): Promise { const username = input.username.toLowerCase(); if (!USERNAME_RE.test(username)) throw new AuthError("invalid_username"); const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" }); return db.k.transaction().execute(async (tx) => { // Serialise registrations on Postgres so two first-time sign-ups cannot both become admin. // SQLite is already serialised by its single connection. if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(727401)`.execute(tx); const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst(); if (anyUser && !input.inviteCode) throw new AuthError("invite_required"); const existing = await tx.selectFrom("users").select("id").where("username", "=", username).executeTakeFirst(); if (existing) throw new AuthError("username_taken"); const id = newId(); const now = nowIso(); const role = anyUser ? "member" : "admin"; const display_name = input.displayName ?? username; await tx .insertInto("users") .values({ id, username, display_name, password_hash: passwordHash, role, preferences: toJson({}), created_at: now, updated_at: now, }) .execute(); // used_by references users.id, so claim after the insert; the tx rolls back on failure. if (anyUser) { const claimed = await tx .updateTable("invites") .set({ used_by: id }) .where("code_hash", "=", sha256Hex(input.inviteCode!)) .where("used_by", "is", null) .where("expires_at", ">", now) .executeTakeFirst(); if (Number(claimed.numUpdatedRows) !== 1) throw new AuthError("invite_required"); } return { id, username, displayName: display_name, role }; }); } export async function login( db: Db, username: string, password: string, ): Promise<{ user: User; sessionToken: string }> { const row = await db.k .selectFrom("users") .selectAll() .where("username", "=", username.toLowerCase()) .executeTakeFirst(); const hash = row?.password_hash ?? (await dummyHash()); const ok = await Bun.password.verify(password, hash); if (!row || !ok) { throw new AuthError("invalid_credentials"); } const sessionToken = randomToken(); await db.k .insertInto("sessions") .values({ id: newId(), user_id: row.id, token_hash: sha256Hex(sessionToken), expires_at: new Date(Date.now() + SESSION_DAYS * DAY_MS).toISOString(), created_at: nowIso(), }) .execute(); return { user: toUser(row), sessionToken }; } /** True once any account exists (the first one becomes admin). */ export async function hasUsers(db: Db): Promise { return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined; } export async function getSessionUser(db: Db, token: string): Promise { const row = await db.k .selectFrom("sessions") .innerJoin("users", "users.id", "sessions.user_id") .select(["users.id", "users.username", "users.display_name", "users.role"]) .where("sessions.token_hash", "=", sha256Hex(token)) .where("sessions.expires_at", ">", nowIso()) .executeTakeFirst(); return row ? toUser(row) : null; } export async function logout(db: Db, token: string): Promise { await db.k.deleteFrom("sessions").where("token_hash", "=", sha256Hex(token)).execute(); } /** Admin-only. Returns the invite code (shown once; only its hash is stored). */ export async function createInvite(db: Db, adminId: string, ttlDays = 7): Promise { const admin = await db.k.selectFrom("users").select("role").where("id", "=", adminId).executeTakeFirst(); if (admin?.role !== "admin") throw new AuthError("forbidden"); const code = randomToken(16); await db.k .insertInto("invites") .values({ id: newId(), code_hash: sha256Hex(code), created_by: adminId, used_by: null, expires_at: new Date(Date.now() + ttlDays * DAY_MS).toISOString(), created_at: nowIso(), }) .execute(); return code; } export async function createAppToken( db: Db, userId: string, name: string, scopes: TokenScope[], ): Promise<{ id: string; token: string }> { const id = newId(); const token = `stt_${randomToken()}`; await db.k .insertInto("app_tokens") .values({ id, user_id: userId, name, token_hash: sha256Hex(token), scopes: toJson(scopes), last_used_at: null, revoked_at: null, created_at: nowIso(), }) .execute(); return { id, token }; } export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise { return (await authenticateAppToken(db, token, scope))?.user ?? null; } /** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:`). */ export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> { const row = await db.k .selectFrom("app_tokens") .innerJoin("users", "users.id", "app_tokens.user_id") .select([ "app_tokens.id as token_id", "app_tokens.name as token_name", "app_tokens.scopes", "users.id", "users.username", "users.display_name", "users.role", ]) .where("app_tokens.token_hash", "=", sha256Hex(token)) .where("app_tokens.revoked_at", "is", null) .executeTakeFirst(); if (!row || !fromJson(row.scopes, []).includes(scope)) return null; await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute(); return { user: toUser(row), tokenName: row.token_name }; } /** Revoke one of the user's live tokens. Returns false when no such live token exists (unknown, foreign or revoked). */ export async function revokeAppToken(db: Db, userId: string, id: string): Promise { const r = await db.k .updateTable("app_tokens") .set({ revoked_at: nowIso() }) .where("id", "=", id) .where("user_id", "=", userId) .where("revoked_at", "is", null) .executeTakeFirst(); return Number(r.numUpdatedRows) === 1; } export interface AppTokenInfo { id: string; name: string; scopes: TokenScope[]; lastUsedAt: string | null; revokedAt: string | null; createdAt: string; } /** The user's app tokens, newest first. Metadata only: the token and its hash are never returned. */ export async function listAppTokens(db: Db, userId: string): Promise { const rows = await db.k .selectFrom("app_tokens") .select(["id", "name", "scopes", "last_used_at", "revoked_at", "created_at"]) .where("user_id", "=", userId) .orderBy("created_at", "desc") .orderBy("id", "desc") .execute(); return rows.map((r) => ({ id: r.id, name: r.name, scopes: fromJson(r.scopes, []), lastUsedAt: r.last_used_at, revokedAt: r.revoked_at, createdAt: r.created_at, })); }