Skip to the content

Capsid menu

app/lib/people.server.ts

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

Functions

The functions of people.server.ts
FunctionLinesComplexityCoveredCRAPMutation
kindOf26 to 283100%OkCRAP 3No data: mutation testing has not run on this repo yet
findViewer31 to 382100%OkCRAP 2No data: mutation testing has not run on this repo yet
findAgentViewer45 to 483100%OkCRAP 3No data: mutation testing has not run on this repo yet
roleOn50 to 583100%OkCRAP 3No data: mutation testing has not run on this repo yet
visibleProjects61 to 792100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:69>69 to 691100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:78>78 to 781100%OkCRAP 1No data: mutation testing has not run on this repo yet
requireAction85 to 953100%OkCRAP 3No 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/roles.ts. Imported by app/lib/ai.server.ts, app/lib/binder.server.ts, app/lib/books.server.ts, app/lib/bulk.server.ts, app/lib/content.server.ts, app/lib/context.ts, app/lib/google/drive.server.ts, app/lib/google/oauth.server.ts, app/lib/legal.server.ts, app/lib/mcp/door.ts, app/lib/mcp/tools.ts, app/lib/media.server.ts, app/lib/mentions.server.ts, app/lib/people-admin.server.ts, app/lib/projects.server.ts, app/lib/social/queue.server.ts, app/lib/waiting.server.ts, app/lib/writing.server.ts, app/routes/frame.tsx, app/routes/home.tsx, app/routes/social.tsx, workers/gate.ts.

Source

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

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

export type Viewer = {
  id: number;
  email: string;
  name: string;
  isOwner: boolean;
  /** Another company's AI agent that reads and flags through MCP (design B6). Never writes or publishes. */
  isReviewer: boolean;
};

export type VisibleProject = {
  id: number;
  slug: string;
  name: string;
  role: Role;
  /** A site's projects open at /p/, a book's at /b/. A project that is neither has no view yet. */
  kind: "site" | "book" | null;
};

function kindOf(row: { site: string | null; book: string | null }): VisibleProject["kind"] {
  return row.site ? "site" : row.book ? "book" : null;
}

/** The active person with this email, or null. Email matching is case-insensitive, as the column is. */
export async function findViewer(db: D1Database, email: string): Promise<Viewer | null> {
  const row = await drizzle(db)
    .select({ id: people.id, email: people.email, name: people.name, isOwner: people.isOwner, isReviewer: people.isReviewer })
    .from(people)
    .where(and(eq(people.email, email.trim()), isNull(people.disabledAt)))
    .get();
  return row ?? null;
}

/**
 * The active person row for a named agent key ("agent:<name>"), or null. The row is where the agent's
 * role lives: the key proves who is knocking and grants nothing. An agent is never the Owner, so a row
 * flagged Owner is refused here, whatever the table says.
 */
export async function findAgentViewer(db: D1Database, name: string): Promise<Viewer | null> {
  const viewer = await findViewer(db, agentEmail(name));
  return viewer && !viewer.isOwner ? viewer : null;
}

async function roleOn(db: D1Database, viewer: Viewer, projectId: number): Promise<Role | null> {
  if (viewer.isOwner) return "owner";
  const row = await drizzle(db)
    .select({ role: projectMembers.role })
    .from(projectMembers)
    .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.personId, viewer.id)))
    .get();
  return row?.role ?? null;
}

/** Every project the viewer may read: all of them for the Owner, shared ones for everyone else. */
export async function visibleProjects(db: D1Database, viewer: Viewer): Promise<VisibleProject[]> {
  const d = drizzle(db);
  if (viewer.isOwner) {
    const rows = await 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 rows.map(({ site, book, ...r }) => ({ ...r, role: "owner" as const, kind: kindOf({ site, book }) }));
  }
  const rows = await d
    .select({ id: projects.id, slug: projects.slug, name: projects.name, role: projectMembers.role, site: projects.site, book: projects.book })
    .from(projectMembers)
    .innerJoin(projects, eq(projects.id, projectMembers.projectId))
    .where(eq(projectMembers.personId, viewer.id))
    .orderBy(asc(projects.name))
    .all();
  return rows.map(({ site, book, ...r }) => ({ ...r, kind: kindOf({ site, book }) }));
}

/**
 * Throws a 404 when the viewer has no role on the project, so a project that is not shared with
 * someone is indistinguishable from one that does not exist, and a 403 when the role is too low.
 */
export async function requireAction(
  db: D1Database,
  viewer: Viewer,
  projectId: number,
  action: Action,
): Promise<Role> {
  const role = await roleOn(db, viewer, projectId);
  if (role === null) throw new Response("Not found", { status: 404 });
  if (!can(role, action)) throw new Response("Forbidden", { status: 403 });
  return role;
}