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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| kindOf | 26 to 28 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| findViewer | 31 to 38 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| findAgentViewer | 45 to 48 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| roleOn | 50 to 58 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| visibleProjects | 61 to 79 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:69> | 69 to 69 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:78> | 78 to 78 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| requireAction | 85 to 95 | 3 | 100% | OkCRAP 3 | No 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;
}