GET auth/status ({ hasUsers }), GET attachments?entity=&entityId= (owner
scoped, 404 for foreign targets) and GET/PATCH me/preferences (ratingScale,
defaultVisibility in users.preferences).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
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<string> | undefined;
|
|
/** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */
|
|
function dummyHash(): Promise<string> {
|
|
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<User> {
|
|
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<boolean> {
|
|
return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined;
|
|
}
|
|
|
|
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
|
|
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<void> {
|
|
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<string> {
|
|
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<User | null> {
|
|
return (await authenticateAppToken(db, token, scope))?.user ?? null;
|
|
}
|
|
|
|
/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:<name>`). */
|
|
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<string[]>(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<boolean> {
|
|
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<AppTokenInfo[]> {
|
|
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<TokenScope[]>(r.scopes, []),
|
|
lastUsedAt: r.last_used_at,
|
|
revokedAt: r.revoked_at,
|
|
createdAt: r.created_at,
|
|
}));
|
|
}
|