import { randomBytes } from "node:crypto"; import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db"; import { isValidOwner } from "./git"; import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles"; import { readStoredImage, uploadedImage } from "./uploaded-images"; import { getUserByUsername, toUser, type User } from "./users"; export * from "./org-roles"; // Organizations are accounts that own repositories for a group of people (see // pb_migrations/1791300000_orgs.js). Their members' roles are their access to every repository the // organization owns; people get one by accepting an invitation from an owner. export type OrgMember = { id: string; username: string; name: string; role: OrgRole; joinedAt: number }; export type OrgInvitation = { id: string; org: User; user: User; role: OrgRole; invitedBy: string | null; createdAt: number }; export class OrgError extends Error {} const members = async () => (await db()).collection("org_members"); const invitations = async () => (await db()).collection("org_invitations"); /** Membership changes are read-modify-write (an organization always keeps an owner). */ const lockOrg = (orgId: string, fn: () => Promise) => withLock(`org:${orgId}`, fn); export async function getOrg(name: string) { const user = await getUserByUsername(name); return user?.isOrg ? user : null; } export async function createOrg(creator: User, input: { name: string; displayName: string; description: string }) { const name = input.name.trim(); if (!isValidOwner(name)) { throw new OrgError("Organization names may only contain letters, numbers and hyphens, and can't be a reserved word."); } if (await getUserByUsername(name)) throw new OrgError(`The name ${name} is already taken.`); // Nobody signs in as an organization; PocketBase still wants a password for the record. const password = randomBytes(36).toString("base64url"); let record: RecordModel; try { record = await (await db()).collection("users").create({ username: name, kind: "org", name: input.displayName.trim().slice(0, 100), bio: input.description.trim().slice(0, 300), password, passwordConfirm: password, }); } catch (e) { if (isUniqueViolation(e)) throw new OrgError(`The name ${name} was just taken.`); throw e; } await (await members()).create({ org: record.id, user: creator.id, role: "owner", created_at: Date.now() }); return toUser(record); } export async function updateOrgProfile(orgId: string, data: { displayName: string; description: string }) { await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) }); } export async function setOrgAvatar(orgId: string, data: Uint8Array) { const image = uploadedImage(data, "avatar"); if ("error" in image) throw new OrgError(image.error); await (await db()).collection("users").update(orgId, { avatar: image.file, avatar_type: image.type, avatar_updated_at: Date.now() }); } export async function removeOrgAvatar(orgId: string) { await (await db()).collection("users").update(orgId, { avatar: null, avatar_type: "", avatar_updated_at: 0 }); } export async function getOrgAvatar(orgId: string) { const row = await findById("users", orgId); return row?.kind === "org" && row.avatar ? readStoredImage(row, row.avatar, row.avatar_type) : null; } /** Deletes an organization without repositories, and its memberships and invitations with it. */ export async function deleteOrg(org: User) { const repos = await count("repos", filter("owner = {:id}", { id: org.id })); if (repos) throw new OrgError(`${org.username} still owns ${repos === 1 ? "a repository. Delete it" : `${repos} repositories. Delete them`} first.`); await (await db()).collection("users").delete(org.id); } // --------------------------------------------------------------------------- // Members /** The person's role in the organization, or null (also when `orgId` is a person's). */ export async function orgRole(orgId: string, userId: string | null | undefined): Promise { if (!userId || orgId === userId) return null; const row = await findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId }), { fields: "role" }); return (row?.role as OrgRole) ?? null; } export async function listOrgMembers(orgId: string): Promise { const rows = await (await members()).getFullList({ filter: filter("org = {:orgId}", { orgId }), expand: "user" }); return rows .map((r) => { const user = r.expand?.user as RecordModel; return { id: user.id, username: user.username as string, name: user.name as string, role: r.role as OrgRole, joinedAt: r.created_at as number }; }) .sort((a, b) => ORG_ROLES.indexOf(b.role) - ORG_ROLES.indexOf(a.role) || a.username.localeCompare(b.username)); } /** The ids of members with at least `role`. */ export async function orgMemberIds(orgId: string, role: OrgRole) { const roles = ORG_ROLES.slice(ORG_ROLES.indexOf(role)); const rows = await (await members()).getFullList({ filter: `${filter("org = {:orgId}", { orgId })} && (${roles.map((r) => filter("role = {:r}", { r })).join(" || ")})`, fields: "user", }); return rows.map((r) => r.user as string); } /** * Matches `field` (a user id) against the organization's members who can push to its repositories, * for filters; "" if there are none (or `orgId` is a person's). */ export async function orgPushersFilter(field: string, orgId: string) { return (await orgMemberIds(orgId, "write")).map((id) => filter(`${field} = {:id}`, { id })).join(" || "); } /** The organizations a person belongs to, by name. */ export async function listOrgsOf(userId: string) { const rows = await (await members()).getFullList({ filter: filter("user = {:userId}", { userId }), expand: "org" }); return rows .map((r) => ({ org: toUser(r.expand?.org as RecordModel), role: r.role as OrgRole, joinedAt: r.created_at as number })) .sort((a, b) => a.org.username.localeCompare(b.org.username)); } /** Where a person may create repositories: their own account, and organizations they're an admin or owner of. */ export async function repoOwnersFor(user: User) { const orgs = (await listOrgsOf(user.id)).filter((m) => roleAtLeast(m.role, "admin")).map((m) => m.org); return [user, ...orgs]; } export async function mayCreateRepos(owner: User, user: User) { return owner.id === user.id || (owner.isOrg && roleAtLeast(await orgRole(owner.id, user.id), "admin")); } async function membership(orgId: string, userId: string) { return findFirst("org_members", filter("org = {:orgId} && user = {:userId}", { orgId, userId })); } /** Refuses to leave the organization without an owner. */ async function keepAnOwner(orgId: string, userId: string) { const owners = await orgMemberIds(orgId, "owner"); if (owners.length === 1 && owners[0] === userId) { throw new OrgError("An organization needs at least one owner. Make someone else an owner first."); } } export async function setMemberRole(orgId: string, userId: string, role: OrgRole) { return lockOrg(orgId, async () => { const row = await membership(orgId, userId); if (!row) throw new OrgError("They're not a member of this organization."); if (row.role === role) return; if (row.role === "owner") await keepAnOwner(orgId, userId); await (await members()).update(row.id, { role }); }); } /** Removes a member, or lets one leave. */ export async function removeMember(orgId: string, userId: string) { return lockOrg(orgId, async () => { const row = await membership(orgId, userId); if (!row) return; if (row.role === "owner") await keepAnOwner(orgId, userId); await (await members()).delete(row.id); }); } // --------------------------------------------------------------------------- // Invitations function toInvitation(r: RecordModel): OrgInvitation { const invitedBy = r.expand?.invited_by as RecordModel | undefined; return { id: r.id, org: toUser(r.expand?.org as RecordModel), user: toUser(r.expand?.user as RecordModel), role: r.role as OrgRole, invitedBy: (invitedBy?.username as string) ?? null, createdAt: r.created_at, }; } const INVITATION_EXPAND = "org,user,invited_by"; export async function inviteMember(org: User, inviter: User, username: string, role: OrgRole) { const user = await getUserByUsername(username.trim()); if (!user || user.isOrg) throw new OrgError(`No user named ${username.trim()}.`); return lockOrg(org.id, async () => { if (await membership(org.id, user.id)) throw new OrgError(`${user.username} is already a member.`); try { await (await invitations()).create({ org: org.id, user: user.id, role, invited_by: inviter.id, created_at: Date.now() }); } catch (e) { if (isUniqueViolation(e)) throw new OrgError(`${user.username} has already been invited.`); throw e; } return user; }); } /** Invitations an organization sent that haven't been answered. */ export async function listOrgInvitations(orgId: string) { const rows = await (await invitations()).getFullList({ filter: filter("org = {:orgId}", { orgId }), sort: "-created_at", expand: INVITATION_EXPAND }); return rows.map(toInvitation); } /** Invitations a person hasn't answered yet. */ export async function listInvitationsFor(userId: string) { const rows = await (await invitations()).getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at", expand: INVITATION_EXPAND }); return rows.map(toInvitation); } export async function cancelInvitation(orgId: string, invitationId: string) { const row = await findById("org_invitations", invitationId); if (row?.org === orgId) await (await invitations()).delete(row.id); } /** Accepts (the person joins with the role they were invited with) or declines an invitation addressed to `userId`. */ export async function answerInvitation(userId: string, invitationId: string, accept: boolean) { const row = await findById("org_invitations", invitationId, { expand: INVITATION_EXPAND }); if (!row || row.user !== userId) throw new OrgError("That invitation doesn't exist anymore."); const invitation = toInvitation(row); if (accept) { await lockOrg(row.org, async () => { try { await (await members()).create({ org: row.org, user: userId, role: row.role, created_at: Date.now() }); } catch (e) { // Already a member. if (!isUniqueViolation(e)) throw e; } }); } await (await invitations()).delete(row.id); return invitation; }