/** * 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 { 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 { 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 "-", 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 { 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 // -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 `-default_project` created, with display name * defaulting to the username. */ async provisionInitialProject(user: UserRow, isAdmin: boolean): Promise { 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 { 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 { 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 { const runnings = this.deps.manager.abortProject(projectId); if (runnings.length > 0) { await Promise.race([ Promise.allSettled(runnings).then(() => undefined), new Promise((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 { 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(), }); }); } }