import { ClientResponseError } from "pocketbase";
import { client, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
import { isValidOwner } from "./git";
// Accounts are PocketBase auth records: PocketBase hashes and checks passwords and issues the
// session tokens (see lib/auth.ts).
export type User = {
id: string;
username: string;
name: string;
email: string;
bio: string;
createdAt: number;
/** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */
isOrg: boolean;
/** When an avatar was uploaded (used to bust caches), or null to draw the initials. */
avatarVersion: number | null;
};
/** PocketBase dates read "2026-01-02 03:04:05.678Z". */
export const parseDate = (value: string) => Date.parse(value.replace(" ", "T"));
export function toUser(r: RecordModel): User {
return {
id: r.id,
username: r.username,
name: r.name,
email: r.email,
bio: r.bio,
createdAt: parseDate(r.created),
isOrg: r.kind === "org",
avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null,
};
}
export function validatePassword(password: string) {
if (password.length < 8) return "Password must be at least 8 characters.";
// PocketBase hashes with bcrypt, which reads at most 72 bytes.
if (Buffer.byteLength(password) > 71) return "Password is too long.";
return null;
}
export class UserError extends Error {}
const isEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const emailTaken = async (email: string, exceptId = "") =>
!!(await findFirst("users", filter("email:lower = {:email} && id != {:id}", { email: email.toLowerCase(), id: exceptId }), { fields: "id" }));
export async function createUser(input: { username: string; email: string; password: string; name?: string }) {
const username = input.username.trim();
const email = input.email.trim();
if (!isValidOwner(username)) {
throw new UserError("Username may only contain letters, numbers and hyphens, and can't be a reserved word.");
}
if (!isEmail(email)) throw new UserError("Email address is invalid.");
const pwError = validatePassword(input.password);
if (pwError) throw new UserError(pwError);
if (await getUserByUsername(username)) throw new UserError(`Username ${username} is not available.`);
if (await emailTaken(email)) throw new UserError("That email address is already registered.");
try {
const record = await (await db()).collection("users").create({
username,
name: input.name?.trim().slice(0, 100) ?? "",
email,
password: input.password,
passwordConfirm: input.password,
});
return toUser(record);
} catch (e) {
if (isUniqueViolation(e)) throw new UserError("That username or email address was just taken.");
throw e;
}
}
/** Looks up a user by username or email and checks the password. Returns a session token with the user. */
export async function authenticateUser(login: string, password: string) {
login = login.trim();
if (!login || !password) return null;
// PocketBase signs in by email; usernames are resolved here, case-insensitively.
const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login: login.toLowerCase() }));
try {
// Unknown logins are still checked, so usernames can't be probed by timing.
const auth = await client().collection("users").authWithPassword(record?.email ?? login, password);
return { user: toUser(auth.record), token: auth.token };
} catch (e) {
if (e instanceof ClientResponseError && e.status === 400) return null;
throw e;
}
}
export async function verifyUserPassword(userId: string, password: string) {
const user = await getUserById(userId);
return !!user && !!(await authenticateUser(user.email, password));
}
/** Changes the password. PocketBase then rejects the user's existing session tokens. */
export async function setPassword(userId: string, password: string) {
await (await db()).collection("users").update(userId, { password, passwordConfirm: password });
}
export async function getUserById(id: string) {
const record = await findById("users", id);
return record ? toUser(record) : null;
}
export async function getUserByUsername(username: string) {
const record = await findFirst("users", filter("username:lower = {:username}", { username: username.toLowerCase() }));
return record ? toUser(record) : null;
}
/** A person (not an organization) by username. */
export async function getPersonByUsername(username: string) {
const user = await getUserByUsername(username);
return user && !user.isOrg ? user : null;
}
/** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */
export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) {
const email = data.email.trim();
if (!isEmail(email)) throw new UserError("Email address is invalid.");
if (await emailTaken(email, userId)) throw new UserError("That email address is already in use.");
const before = await getUserById(userId);
try {
await (await db()).collection("users").update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300) });
} catch (e) {
if (isUniqueViolation(e)) throw new UserError("That email address is already in use.");
throw e;
}
return { emailChanged: before?.email !== email };
}
export async function searchUsers(query: string, limit = 8) {
const { items } = await (await db())
.collection("users")
.getList(1, limit, { filter: filter("kind != 'org' && (username ~ {:q} || name ~ {:q})", { q: query }), sort: "username", skipTotal: true });
return items.map(toUser);
}