Skip to the content

Capsid menu

app/lib/people-admin.server.ts

131 lines and 14 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of people-admin.server.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor18 to 211100%OkCRAP 1No data: mutation testing has not run on this repo yet
requireOwner25 to 273100%OkCRAP 3No data: mutation testing has not run on this repo yet
listPeople41 to 601100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:56>56 to 561100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:56>56 to 561100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:56>56 to 561100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:57>57 to 571100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:58>58 to 583100%OkCRAP 3No data: mutation testing has not run on this repo yet
addPerson68 to 733100%OkCRAP 3No data: mutation testing has not run on this repo yet
insertPerson76 to 852100%OkCRAP 2No data: mutation testing has not run on this repo yet
addAgent92 to 973100%OkCRAP 3No data: mutation testing has not run on this repo yet
target99 to 107275%OkCRAP 2.1No data: mutation testing has not run on this repo yet
setProjectRole114 to 130693%WatchCRAP 6No data: mutation testing has not run on this repo yet
setDisabled137 to 1455100%OkCRAP 5No data: mutation testing has not run on this repo yet

Imports

It imports app/db/schema.ts, app/lib/agent-keys.server.ts, app/lib/people.server.ts, app/lib/roles.ts. Imported by app/routes/people.tsx.

Source

// Managing who is in Carrel (Owner only): add a person, set or remove their role on a project,
// disable or re-enable them. Every change goes through here, with the Owner check inside each
// function, so the page and any later tool meet the same rule.
//
// Carrel's rows decide what a person may do once they are in. Getting in is Cloudflare Access, which
// Carrel does not manage: adding a person here does not let them past Access (people.tsx says where).

import { and, asc, eq, isNull } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";

import { people, projectMembers, projects } from "~/db/schema";
import { agentEmail, isAgentName } from "~/lib/agent-keys.server";
import type { Viewer } from "~/lib/people.server";
import type { Role } from "~/lib/roles";

/** A refusal the Owner reads on the page: the rule said no, and nothing changed. */
export class PeopleRefusal extends Error {
  constructor(message: string) {
    super(message);
    this.name = "PeopleRefusal";
  }
}

/** Anyone but the Owner is answered as if the page did not exist. */
function requireOwner(viewer: Viewer) {
  if (!viewer.isOwner || viewer.isReviewer) throw new Response("Not found", { status: 404 });
}

export type PersonRow = {
  id: number;
  email: string;
  name: string;
  isOwner: boolean;
  isReviewer: boolean;
  disabledAt: string | null;
  roles: { projectId: number; role: Exclude<Role, "owner"> }[];
};

export type ProjectRow = { id: number; slug: string; name: string; kind: "site" | "book" | null };

export async function listPeople(db: D1Database, viewer: Viewer): Promise<{ people: PersonRow[]; projects: ProjectRow[] }> {
  requireOwner(viewer);
  const d = drizzle(db);
  const [rows, members, projectRows] = await Promise.all([
    d
      .select({ id: people.id, email: people.email, name: people.name, isOwner: people.isOwner, isReviewer: people.isReviewer, disabledAt: people.disabledAt })
      .from(people)
      .orderBy(asc(people.email))
      .all(),
    d.select().from(projectMembers).all(),
    d.select({ id: projects.id, slug: projects.slug, name: projects.name, site: projects.site, book: projects.book }).from(projects).orderBy(asc(projects.name)).all(),
  ]);
  return {
    // The Owner first: the one row nothing here can change.
    people: rows
      .map((p) => ({ ...p, roles: members.filter((m) => m.personId === p.id).map(({ projectId, role }) => ({ projectId, role })) }))
      .sort((a, b) => Number(b.isOwner) - Number(a.isOwner)),
    projects: projectRows.map(({ site, book, ...p }) => ({ ...p, kind: site ? "site" : book ? "book" : null })),
  };
}

const EMAIL = /^[^\s@'"<>]+@[^\s@'"<>]+\.[^\s@'"<>]+$/;

/**
 * Adds a person. Never an Owner: Carrel has one Owner, and this function cannot make another (the
 * database refuses a second as well). An email already in Carrel is refused rather than changed.
 */
export async function addPerson(db: D1Database, viewer: Viewer, input: { email: string; name: string; reviewer: boolean }): Promise<PersonRow> {
  requireOwner(viewer);
  const email = input.email.trim().toLowerCase();
  if (!EMAIL.test(email) || email.length > 254) throw new PeopleRefusal("That is not an email address.");
  return insertPerson(db, { email, name: input.name.trim().slice(0, 200), isReviewer: input.reviewer });
}

/** Inserts a person who is never an Owner and has no role yet, refusing an email Carrel already has. */
async function insertPerson(db: D1Database, values: { email: string; name: string; isReviewer: boolean }): Promise<PersonRow> {
  const d = drizzle(db);
  const existing = await d.select({ id: people.id }).from(people).where(eq(people.email, values.email)).get();
  if (existing) throw new PeopleRefusal(`${values.email} is already in Carrel.`);
  const [row] = await d
    .insert(people)
    .values({ ...values, isOwner: false })
    .returning({ id: people.id, email: people.email, name: people.name, isOwner: people.isOwner, isReviewer: people.isReviewer, disabledAt: people.disabledAt });
  return { ...row!, roles: [] };
}

/**
 * Adds a named AI agent: the person row "agent:<name>" that an AGENT_KEY_<NAME> secret resolves to at
 * the AI door. Never an Owner, never a reviewer, and it starts with no role on any project. Adding the
 * row grants nothing by itself: the key is a Worker secret the Owner sets, and the role is set below.
 */
export async function addAgent(db: D1Database, viewer: Viewer, input: { name: string; label?: string }): Promise<PersonRow> {
  requireOwner(viewer);
  const name = input.name.trim().toLowerCase();
  if (!isAgentName(name)) throw new PeopleRefusal("An agent's name is letters, digits, hyphens and underscores, up to 32, such as grok.");
  return insertPerson(db, { email: agentEmail(name), name: input.label?.trim().slice(0, 200) || name, isReviewer: false });
}

async function target(db: D1Database, personId: number) {
  const row = await drizzle(db)
    .select({ id: people.id, email: people.email, isOwner: people.isOwner, isReviewer: people.isReviewer, disabledAt: people.disabledAt })
    .from(people)
    .where(eq(people.id, personId))
    .get();
  if (!row) throw new PeopleRefusal("No such person.");
  return row;
}

/**
 * Sets a person's role on a project, or removes it (role null). The Owner has no per-project role to
 * set: the Owner is the Owner everywhere, and cannot be demoted here. A reviewer reads and flags only,
 * so a reviewer can be a Reader and never an Editor.
 */
export async function setProjectRole(db: D1Database, viewer: Viewer, personId: number, projectId: number, role: "reader" | "editor" | null): Promise<void> {
  requireOwner(viewer);
  const person = await target(db, personId);
  if (person.isOwner) throw new PeopleRefusal("The Owner's role cannot be changed: the Owner is the Owner on every project.");
  if (role === "editor" && person.isReviewer) throw new PeopleRefusal(`${person.email} is a reviewer. Reviewers read and flag only, so they can be a Reader, never an Editor.`);
  const d = drizzle(db);
  const project = await d.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).get();
  if (!project) throw new PeopleRefusal("No such project.");
  if (role === null) {
    await d.delete(projectMembers).where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.personId, personId)));
    return;
  }
  await d
    .insert(projectMembers)
    .values({ projectId, personId, role })
    .onConflictDoUpdate({ target: [projectMembers.projectId, projectMembers.personId], set: { role } });
}

/**
 * Disables a person (they are refused at both doors at once, because every request looks the person
 * up again) or re-enables them. The Owner cannot be disabled: that would lock Carrel with no one able
 * to undo it.
 */
export async function setDisabled(db: D1Database, viewer: Viewer, personId: number, disabled: boolean, now = new Date()): Promise<void> {
  requireOwner(viewer);
  const person = await target(db, personId);
  if (person.isOwner || person.id === viewer.id) throw new PeopleRefusal("The Owner cannot be disabled.");
  await drizzle(db)
    .update(people)
    .set({ disabledAt: disabled ? now.toISOString() : null })
    .where(disabled ? and(eq(people.id, personId), isNull(people.disabledAt)) : eq(people.id, personId));
}