ab388b4f8c
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/**
|
||
* Project service.
|
||
*
|
||
* The single implementation point for authorization rules: `requireProjectAccess`
|
||
* (owner or member, otherwise 404 without leaking existence) and
|
||
* `requireProjectOwner` (owner only; 403 when known to be accessible, 404 when not)
|
||
* are reused by every route; the non-throwing `canAccess` (for error attribution)
|
||
* is likewise just a sibling wrapper around them — all three share the single
|
||
* `resolveAccess` decision, with no second rule set maintained separately.
|
||
* Also handles Project create / list / delete, member authorization, and initial
|
||
* Project provisioning at signup.
|
||
*/
|
||
import fs from "node:fs/promises";
|
||
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
|
||
import type { MemberInfo, ProjectRole, ProjectSummary } from "../api/types.js";
|
||
import { HttpError } from "../http/errors.js";
|
||
import type { AgentsRepo } from "../db/repos/agents.js";
|
||
import type { ErrorsRepo } from "../db/repos/errors.js";
|
||
import type { MembersRepo } from "../db/repos/members.js";
|
||
import type { GoalsRepo } from "../db/repos/goals.js";
|
||
import type { ProjectRow, ProjectsRepo } from "../db/repos/projects.js";
|
||
import type { SessionsRepo } from "../db/repos/sessions.js";
|
||
import type { SchedulesRepo } from "../db/repos/schedules.js";
|
||
import type { UsageRepo } from "../db/repos/usage.js";
|
||
import type { UserRow, UsersRepo } from "../db/repos/users.js";
|
||
import type { SessionManager } from "../runtime/session-manager.js";
|
||
import {
|
||
PROJECT_ID_MAX_LENGTH,
|
||
PROJECT_SUFFIX_PATTERN,
|
||
SEMANTIC_ID_PATTERN,
|
||
SEMANTIC_ID_RULE,
|
||
} from "./ids.js";
|
||
import type { ProjectConfigService } from "./project-config-service.js";
|
||
import type { TraceIndexService } from "./trace-index.js";
|
||
|
||
/** Fallback timeout for waiting on runs to settle before deleting a Project. */
|
||
const ABORT_SETTLE_TIMEOUT_MS = 5000;
|
||
|
||
async function dirExists(path: string): Promise<boolean> {
|
||
try {
|
||
const stat = await fs.stat(path);
|
||
return stat.isDirectory();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export interface ProjectServiceDeps {
|
||
root: string;
|
||
users: UsersRepo;
|
||
projects: ProjectsRepo;
|
||
members: MembersRepo;
|
||
agents: AgentsRepo;
|
||
sessions: SessionsRepo;
|
||
usage: UsageRepo;
|
||
errors: ErrorsRepo;
|
||
schedules: SchedulesRepo;
|
||
goals: GoalsRepo;
|
||
projectConfig: ProjectConfigService;
|
||
manager: SessionManager;
|
||
/** Trace-index cleanup on Project destruction (rows describe files removed with the Project dir). */
|
||
traceIndex: TraceIndexService;
|
||
}
|
||
|
||
export class ProjectService {
|
||
constructor(private readonly deps: ProjectServiceDeps) {}
|
||
|
||
// —— Authorization rules (single implementation point) ——
|
||
|
||
/**
|
||
* The **sole** implementation of the owner / member check: returns the row with
|
||
* a role if accessible, otherwise null. `requireProjectAccess` below (throws 404)
|
||
* and `canAccess` (returns boolean) are both just wrappers around it — there's
|
||
* only one copy of the decision rule, since writing it twice would eventually
|
||
* drift out of sync.
|
||
*/
|
||
private resolveAccess(
|
||
userId: string,
|
||
projectId: string,
|
||
): (ProjectRow & { role: ProjectRole }) | null {
|
||
const row = this.deps.projects.findById(projectId);
|
||
if (!row) return null;
|
||
if (row.ownerUserId === userId) return { ...row, role: "owner" };
|
||
if (this.deps.members.isMember(projectId, userId)) return { ...row, role: "member" };
|
||
return null;
|
||
}
|
||
|
||
/** Accessible by owner or member; otherwise 404 (does not leak Project existence). */
|
||
requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } {
|
||
const row = this.resolveAccess(userId, projectId);
|
||
if (!row) {
|
||
throw new HttpError(
|
||
404,
|
||
"project_not_found",
|
||
"Project does not exist or you do not have access.",
|
||
);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
/**
|
||
* The non-throwing version of the same check: used for **error attribution**
|
||
* (app.onError) — that runs on the error-handling path, where throwing another
|
||
* 404 would only break error handling; whether access is granted shouldn't be
|
||
* expressed as an exception there anyway.
|
||
*/
|
||
canAccess(userId: string, projectId: string): boolean {
|
||
return this.resolveAccess(userId, projectId) !== null;
|
||
}
|
||
|
||
/** Owner only: 403 when known accessible as a member; 404 when not accessible. */
|
||
requireProjectOwner(userId: string, projectId: string): ProjectRow {
|
||
const row = this.requireProjectAccess(userId, projectId);
|
||
if (row.role !== "owner") {
|
||
throw new HttpError(
|
||
403,
|
||
"owner_required",
|
||
"Only the Project owner can perform this operation.",
|
||
);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
/** List of project_ids accessible to the current user (owned + granted access) (used by workspace-guard). */
|
||
accessibleProjectIds(userId: string): string[] {
|
||
return this.deps.projects.listAccessible(userId).map((p) => p.projectId);
|
||
}
|
||
|
||
// —— Project lifecycle ——
|
||
|
||
/** List of owned + granted-access Projects; display names are read from each project_config.toml. */
|
||
async listProjects(userId: string): Promise<ProjectSummary[]> {
|
||
const rows = this.deps.projects.listAccessible(userId);
|
||
return Promise.all(
|
||
rows.map(async (row) => {
|
||
const name = await this.deps.projectConfig.getName(row.projectId);
|
||
return {
|
||
projectId: row.projectId,
|
||
...(name !== undefined ? { name } : {}),
|
||
role: row.role,
|
||
ownerUserId: row.ownerUserId,
|
||
createdAt: row.createdAt,
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Create a Project: the id is chosen by the creator (a semantic id, checked for
|
||
* duplicates against both the DB and the directory — 409 if taken), the initial
|
||
* config is written (display name defaults to the id), and the built-in Agent is
|
||
* initialized.
|
||
* A non-admin's id is forced to be "<username>-<suffix>", where the suffix is
|
||
* lowercase letters, digits, and underscores only — the hyphen is a reserved
|
||
* separator, usernames never contain a hyphen, so the first hyphen is the
|
||
* ownership boundary and the prefix can never be crafted from another username;
|
||
* an admin's id contains no hyphen (occupying no user's namespace).
|
||
*/
|
||
async createProject(owner: UserRow, projectId: string, name?: string): Promise<ProjectSummary> {
|
||
if (owner.isAdmin) {
|
||
if (!SEMANTIC_ID_PATTERN.test(projectId)) {
|
||
throw new HttpError(
|
||
400,
|
||
"invalid_project_id",
|
||
`Project id must be 2–64 characters: ${SEMANTIC_ID_RULE} (the hyphen is reserved as the user namespace separator).`,
|
||
);
|
||
}
|
||
} else {
|
||
const prefix = `${owner.userId}-`;
|
||
const suffix = projectId.startsWith(prefix) ? projectId.slice(prefix.length) : "";
|
||
if (!PROJECT_SUFFIX_PATTERN.test(suffix) || projectId.length > PROJECT_ID_MAX_LENGTH) {
|
||
throw new HttpError(
|
||
400,
|
||
"project_id_prefix_required",
|
||
`Project id must start with ${prefix}, followed by lowercase letters, digits or underscores (at most ${PROJECT_ID_MAX_LENGTH} in total).`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
this.deps.projects.findById(projectId) !== null ||
|
||
(await dirExists(projectDir(this.deps.root, projectId)))
|
||
) {
|
||
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
|
||
}
|
||
const displayName = name ?? projectId;
|
||
const createdAt = new Date().toISOString();
|
||
// Insert the DB row first: the primary key is the final arbiter for concurrent
|
||
// creation with the same id (the duplicate check above has an await gap), and a
|
||
// conflict is mapped to 409 with **no cleanup** — the directory belongs to the
|
||
// winner, and cleaning up here would wrongly delete the other side's data.
|
||
try {
|
||
this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt });
|
||
} catch (err) {
|
||
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
||
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
|
||
}
|
||
throw err;
|
||
}
|
||
// If file initialization fails, roll back the DB row and clean up the
|
||
// directory: an orphaned directory would make retries with this id 409 forever
|
||
// (a typical scenario: signup failure rolled back the user row, but the
|
||
// <username>-default_project directory was left behind).
|
||
try {
|
||
await fs.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
|
||
await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
|
||
await this.provisionBuiltinAgents(projectId);
|
||
} catch (err) {
|
||
this.deps.projects.delete(projectId);
|
||
await fs
|
||
.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true })
|
||
.catch(() => {});
|
||
throw err;
|
||
}
|
||
return {
|
||
projectId,
|
||
name: displayName,
|
||
role: "owner",
|
||
ownerUserId: owner.userId,
|
||
createdAt,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Initial Project provisioned at signup:
|
||
* the built-in admin adopts `default_project` (if the directory already exists,
|
||
* it's adopted directly without overwriting existing config — shared with the
|
||
* CLI); other users get `<username>-default_project` created, with display name
|
||
* defaulting to the username.
|
||
*/
|
||
async provisionInitialProject(user: UserRow, isAdmin: boolean): Promise<void> {
|
||
if (!isAdmin) {
|
||
await this.createProject(user, `${user.userId}-${DEFAULT_PROJECT_ID}`, user.userId);
|
||
return;
|
||
}
|
||
const projectId = DEFAULT_PROJECT_ID;
|
||
// Initialize the built-in Agent (loaded without overwriting if it already exists); this also ensures the directory exists.
|
||
await this.provisionBuiltinAgents(projectId);
|
||
// Adopting an existing directory doesn't go through writeInitialConfig: preset
|
||
// models and the default model are backfilled instead (only when there are no
|
||
// models at all; a default_project already configured via the CLI is left
|
||
// as-is).
|
||
await this.deps.projectConfig.ensurePresetModels(projectId);
|
||
this.deps.projects.insert({
|
||
projectId,
|
||
ownerUserId: user.userId,
|
||
createdAt: new Date().toISOString(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Rename a Project's display name (owner): the id stays immutable — it names the directory,
|
||
* the Workspace paths and every stored reference — so only the label in project_config.toml
|
||
* changes. Returns the refreshed summary so the caller can swap it into its list without a
|
||
* reload.
|
||
*/
|
||
async renameProject(userId: string, projectId: string, name: string): Promise<ProjectSummary> {
|
||
const row = this.requireProjectOwner(userId, projectId);
|
||
await this.deps.projectConfig.setName(projectId, name);
|
||
// requireProjectOwner already established the role; it returns the plain row.
|
||
return {
|
||
projectId,
|
||
name,
|
||
role: "owner",
|
||
ownerUserId: row.ownerUserId,
|
||
createdAt: row.createdAt,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Delete a Project (owner): default_project is refused; deleting the user's
|
||
* **last accessible Project** is refused too (deleting it would leave the list
|
||
* empty, with no Project to select in the Web client and the page stuck on a
|
||
* skeleton screen — a typical case being a non-first user deleting the initial
|
||
* Project provisioned at signup); active runs are drained first, then the DB and
|
||
* directory are cleared.
|
||
*/
|
||
async deleteProject(userId: string, projectId: string): Promise<void> {
|
||
this.requireProjectOwner(userId, projectId);
|
||
if (projectId === DEFAULT_PROJECT_ID) {
|
||
throw new HttpError(
|
||
409,
|
||
"cannot_delete_default_project",
|
||
"default_project is shared with the CLI and cannot be deleted from the web.",
|
||
);
|
||
}
|
||
if (this.deps.projects.listAccessible(userId).length <= 1) {
|
||
throw new HttpError(
|
||
409,
|
||
"cannot_delete_last_project",
|
||
"This is the account's last Project; deleting it would leave no Project available. Create a new Project first.",
|
||
);
|
||
}
|
||
await this.destroyProject(projectId);
|
||
}
|
||
|
||
/**
|
||
* The actual deletion (no authorization or protection checks): shared by
|
||
* deleteProject and the cascade cleanup when an admin deletes a user.
|
||
* Abort follow-up (writing the abort event to Trace, etc.) happens
|
||
* asynchronously: waits for runs to settle (capped at 5s) before deleting the
|
||
* directory, to avoid the Trace writer recreating the directory after deletion.
|
||
*/
|
||
async destroyProject(projectId: string): Promise<void> {
|
||
const runnings = this.deps.manager.abortProject(projectId);
|
||
if (runnings.length > 0) {
|
||
await Promise.race([
|
||
Promise.allSettled(runnings).then(() => undefined),
|
||
new Promise<void>((resolve) => setTimeout(resolve, ABORT_SETTLE_TIMEOUT_MS).unref?.()),
|
||
]);
|
||
}
|
||
this.deps.projects.delete(projectId); // project_members cascade-deleted
|
||
this.deps.agents.deleteByProject(projectId);
|
||
this.deps.sessions.deleteByProject(projectId);
|
||
this.deps.usage.deleteByProject(projectId);
|
||
this.deps.errors.deleteByProject(projectId);
|
||
this.deps.schedules.deleteByProject(projectId);
|
||
this.deps.goals.deleteByProject(projectId);
|
||
this.deps.traceIndex.removeProject(projectId);
|
||
await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
||
}
|
||
|
||
// —— Member authorization ——
|
||
|
||
/** Member list: owner (role=owner) + members. */
|
||
listMembers(userId: string, projectId: string): MemberInfo[] {
|
||
const project = this.requireProjectAccess(userId, projectId);
|
||
const members = this.deps.members.list(projectId);
|
||
return [
|
||
{ userId: project.ownerUserId, role: "owner", createdAt: project.createdAt },
|
||
...members.map((m) => ({
|
||
userId: m.userId,
|
||
role: "member" as const,
|
||
createdAt: m.createdAt,
|
||
})),
|
||
];
|
||
}
|
||
|
||
/** Grant member access (owner): invites by username; 404 if the user doesn't exist, 409 for the owner themself or an existing member. */
|
||
addMember(userId: string, projectId: string, targetUserId: string): MemberInfo {
|
||
const project = this.requireProjectOwner(userId, projectId);
|
||
const target = this.deps.users.findById(targetUserId);
|
||
if (!target) {
|
||
throw new HttpError(404, "user_not_found", `User does not exist: ${targetUserId}.`);
|
||
}
|
||
if (target.userId === project.ownerUserId) {
|
||
throw new HttpError(
|
||
409,
|
||
"already_owner",
|
||
"The owner does not need to grant access to themselves.",
|
||
);
|
||
}
|
||
if (this.deps.members.isMember(projectId, target.userId)) {
|
||
throw new HttpError(
|
||
409,
|
||
"already_member",
|
||
`${targetUserId} is already a member of this Project.`,
|
||
);
|
||
}
|
||
const createdAt = new Date().toISOString();
|
||
this.deps.members.insert({ projectId, userId: target.userId, createdAt });
|
||
return { userId: target.userId, role: "member", createdAt };
|
||
}
|
||
|
||
/** Revoke member access (owner). */
|
||
removeMember(userId: string, projectId: string, targetUserId: string): void {
|
||
this.requireProjectOwner(userId, projectId);
|
||
if (!this.deps.members.isMember(projectId, targetUserId)) {
|
||
throw new HttpError(404, "member_not_found", `This Project has no member: ${targetUserId}.`);
|
||
}
|
||
this.deps.members.delete(projectId, targetUserId);
|
||
}
|
||
|
||
/**
|
||
* Ensures the Project's built-in Agent exists (the sole built-in Agent
|
||
* default_agent; initialized if the directory is empty, otherwise loaded without
|
||
* overwriting) and indexes it. createdAt increments by 1ms in preset order, so
|
||
* built-in Agents stably sort first; other Agents backfilled by directory
|
||
* scanning are sorted by their own createdAt and are outside the scope of this
|
||
* guarantee.
|
||
*/
|
||
private async provisionBuiltinAgents(projectId: string): Promise<void> {
|
||
const agentIds = await provisionProjectAgents({ root: this.deps.root, projectId });
|
||
const base = Date.now();
|
||
agentIds.forEach((agentId, i) => {
|
||
this.deps.agents.insertOrIgnore({
|
||
projectId,
|
||
agentId,
|
||
createdAt: new Date(base + i).toISOString(),
|
||
});
|
||
});
|
||
}
|
||
}
|