projects.ts285 lines · main
1import { randomBytes } from 'node:crypto';
2
3import { newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, eq, isNull } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import { dbNameFor, dropProjectDatabase, provisionProjectDatabase } from '../db/data-plane.js';
8import {
9 projects,
10 projectMembers,
11 orgMembers,
12 type Project,
13 type NewProject,
14} from '../db/schema.js';
15import { log } from '../lib/logger.js';
16import { resolveProjectAccess, type ProjectAccess } from './access.js';
17import { getTierForOrg } from './billing.js';
18import { McpPlanRequiredError, enableForProject } from './mcp-access.js';
19import { assertProjectCreateAllowed } from './tiers.js';
20
21const SLUG_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
22
23function genSlug(): string {
24 // 12 chars of url-safe lowercase alphanumerics.
25 const bytes = randomBytes(12);
26 let s = '';
27 for (let i = 0; i < 12; i++) {
28 s += SLUG_ALPHABET[bytes[i]! % SLUG_ALPHABET.length];
29 }
30 return s;
31}
32
33const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
34
35export function isValidSlug(slug: string): boolean {
36 return SLUG_RE.test(slug);
37}
38
39export interface CreateProjectInput {
40 name: string;
41 orgId: string;
42 createdByUserId: string;
43 slug?: string;
44 region?: string;
45}
46
47export async function createProject(input: CreateProjectInput): Promise<Project> {
48 const slug = input.slug ?? genSlug();
49 if (!isValidSlug(slug)) {
50 throw new ValidationError('slug must be lowercase alphanumeric with hyphens, 1-32 chars', {
51 slug,
52 });
53 }
54
55 const tier = await getTierForOrg(input.orgId);
56 await assertProjectCreateAllowed(input.orgId, tier);
57
58 const projectId = newId('p');
59 const row: NewProject = {
60 id: projectId,
61 slug,
62 name: input.name,
63 orgId: input.orgId,
64 region: input.region ?? 'eu-west-1',
65 tier,
66 // Field name kept for API/schema stability; value is now the per-project
67 // DoltGres database name (database-per-project), not a schema name.
68 dataSchemaName: dbNameFor(projectId),
69 };
70
71 const db = getDb();
72 const [created] = await db.insert(projects).values(row).returning();
73 if (!created) throw new Error('project insert returned no row');
74
75 // Owner is automatically a member with role=owner. Phase 3 RBAC fleshes
76 // out the other roles; the row exists from day one for consistency.
77 await db.insert(projectMembers).values({
78 projectId: created.id,
79 userId: input.createdByUserId,
80 role: 'owner',
81 });
82
83 // Provision the data-plane DATABASE (database-per-project on DoltGres).
84 // If this fails we roll back the meta rows so the user can retry with the
85 // same slug — leaving the meta row behind would orphan the project (no
86 // database, name/slug taken).
87 try {
88 await provisionProjectDatabase(created.id);
89 } catch (err) {
90 log.error('project_database_provision_failed', {
91 projectId: created.id,
92 message: err instanceof Error ? err.message : String(err),
93 });
94 // Best-effort cleanup. Each step is independent; we don't want a
95 // secondary failure to mask the original cause.
96 try {
97 await dropProjectDatabase(created.id);
98 } catch (dropErr) {
99 log.warn('project_rollback_database_drop_failed', {
100 projectId: created.id,
101 message: dropErr instanceof Error ? dropErr.message : String(dropErr),
102 });
103 }
104 try {
105 await db.delete(projectMembers).where(eq(projectMembers.projectId, created.id));
106 } catch (memberErr) {
107 log.warn('project_rollback_member_delete_failed', {
108 projectId: created.id,
109 message: memberErr instanceof Error ? memberErr.message : String(memberErr),
110 });
111 }
112 try {
113 await db.delete(projects).where(eq(projects.id, created.id));
114 } catch (rowErr) {
115 log.warn('project_rollback_row_delete_failed', {
116 projectId: created.id,
117 message: rowErr instanceof Error ? rowErr.message : String(rowErr),
118 });
119 }
120 throw err;
121 }
122
123 // Agent access (MCP) is ON by default for plan-eligible projects — the
124 // operator wants every current AND future project reachable by agents
125 // without a manual toggle (keys stay per-project and explicit). Best
126 // effort: a plan-gate refusal (free tier, no comp) or a transient
127 // failure must never fail project creation itself.
128 try {
129 await enableForProject(created.id, {
130 id: input.createdByUserId,
131 ipHash: null,
132 userAgent: 'project-create-auto-enable',
133 });
134 } catch (err) {
135 if (!(err instanceof McpPlanRequiredError)) {
136 log.warn('project_mcp_auto_enable_failed', {
137 projectId: created.id,
138 message: err instanceof Error ? err.message : String(err),
139 });
140 }
141 }
142
143 return created;
144}
145
146/**
147 * Every project the user can see, via either an `orgMembers` row for the
148 * project's org or a `projectMembers` row for the project itself
149 * (Model B — org-as-baseline + project overrides). De-duplicated and
150 * ordered by createdAt desc.
151 */
152export async function listProjectsForUser(userId: string): Promise<Project[]> {
153 const db = getDb();
154 const [orgScoped, projectScoped] = await Promise.all([
155 db
156 .select()
157 .from(projects)
158 .innerJoin(orgMembers, eq(orgMembers.orgId, projects.orgId))
159 .where(and(eq(orgMembers.userId, userId), isNull(projects.deletedAt))),
160 db
161 .select()
162 .from(projects)
163 .innerJoin(projectMembers, eq(projectMembers.projectId, projects.id))
164 .where(and(eq(projectMembers.userId, userId), isNull(projects.deletedAt))),
165 ]);
166 const seen = new Set<string>();
167 const merged: Project[] = [];
168 for (const r of [...orgScoped, ...projectScoped]) {
169 if (seen.has(r.projects.id)) continue;
170 seen.add(r.projects.id);
171 merged.push(r.projects);
172 }
173 merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
174 return merged;
175}
176
177export async function getProjectForUser(projectId: string, userId: string): Promise<Project> {
178 const access = await resolveProjectAccess(projectId, userId);
179 if (!access) throw new NotFoundError('project', projectId);
180 return access.project;
181}
182
183/**
184 * Resolve `{ project, effectiveRole }` for a session-authenticated request.
185 * Throws `NotFoundError` if the user has no access via either org or
186 * project membership. Routes that need to gate by role read the role from
187 * this return value or from `c.get('projectRole')` after the middleware
188 * runs.
189 */
190export async function getProjectAccessForUser(
191 projectId: string,
192 userId: string,
193): Promise<ProjectAccess> {
194 const access = await resolveProjectAccess(projectId, userId);
195 if (!access) throw new NotFoundError('project', projectId);
196 return access;
197}
198
199/**
200 * Fetch a project row without a per-user membership check — callers
201 * that reach here have already been auth-gated by requireProjectAuth
202 * (which resolves either a session owner OR a project-scoped API key).
203 * Used by the `/v1/projects/:id/info` verify-credentials endpoint.
204 */
205export async function getProjectInfo(projectId: string): Promise<Project> {
206 const db = getDb();
207 const [row] = await db
208 .select()
209 .from(projects)
210 .where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
211 .limit(1);
212 if (!row) throw new NotFoundError('project', projectId);
213 return row;
214}
215
216export interface UpdateProjectInput {
217 name?: string;
218 slug?: string;
219}
220
221export async function updateProjectForUser(
222 projectId: string,
223 userId: string,
224 input: UpdateProjectInput,
225): Promise<Project> {
226 const existing = await getProjectForUser(projectId, userId);
227 if (input.slug && !isValidSlug(input.slug)) {
228 throw new ValidationError('slug must be lowercase alphanumeric with hyphens, 1-32 chars', {
229 slug: input.slug,
230 });
231 }
232
233 const db = getDb();
234 const [updated] = await db
235 .update(projects)
236 .set({
237 name: input.name ?? existing.name,
238 slug: input.slug ?? existing.slug,
239 updatedAt: new Date(),
240 })
241 .where(eq(projects.id, projectId))
242 .returning();
243 if (!updated) throw new Error('project update returned no row');
244 return updated;
245}
246
247export async function softDeleteProjectForUser(
248 projectId: string,
249 userId: string,
250): Promise<Project> {
251 await getProjectForUser(projectId, userId);
252 const db = getDb();
253 const now = new Date();
254 const [deleted] = await db
255 .update(projects)
256 .set({ deletedAt: now, updatedAt: now })
257 .where(eq(projects.id, projectId))
258 .returning();
259 if (!deleted) throw new Error('project delete returned no row');
260 return deleted;
261}
262
263/**
264 * Re-parent a project to a different org. Caller must be a member of both
265 * the source org (already checked via getProjectForUser) AND the target
266 * org. Billing and tier roll up to the new org from the next subscription
267 * event onward — the existing projects.tier cache flips immediately so
268 * rate-limit middleware sees the new tier on the next request.
269 */
270export async function moveProjectToOrg(args: {
271 projectId: string;
272 userId: string;
273 targetOrgId: string;
274}): Promise<Project> {
275 const project = await getProjectForUser(args.projectId, args.userId);
276 if (project.orgId === args.targetOrgId) return project;
277 const db = getDb();
278 const [updated] = await db
279 .update(projects)
280 .set({ orgId: args.targetOrgId, updatedAt: new Date() })
281 .where(eq(projects.id, args.projectId))
282 .returning();
283 if (!updated) throw new Error('project move returned no row');
284 return updated;
285}