projects.ts285 lines · main
| 1 | import { randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, eq, isNull } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { dbNameFor, dropProjectDatabase, provisionProjectDatabase } from '../db/data-plane.js'; |
| 8 | import { |
| 9 | projects, |
| 10 | projectMembers, |
| 11 | orgMembers, |
| 12 | type Project, |
| 13 | type NewProject, |
| 14 | } from '../db/schema.js'; |
| 15 | import { log } from '../lib/logger.js'; |
| 16 | import { resolveProjectAccess, type ProjectAccess } from './access.js'; |
| 17 | import { getTierForOrg } from './billing.js'; |
| 18 | import { McpPlanRequiredError, enableForProject } from './mcp-access.js'; |
| 19 | import { assertProjectCreateAllowed } from './tiers.js'; |
| 20 | |
| 21 | const SLUG_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; |
| 22 | |
| 23 | function 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 | |
| 33 | const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; |
| 34 | |
| 35 | export function isValidSlug(slug: string): boolean { |
| 36 | return SLUG_RE.test(slug); |
| 37 | } |
| 38 | |
| 39 | export interface CreateProjectInput { |
| 40 | name: string; |
| 41 | orgId: string; |
| 42 | createdByUserId: string; |
| 43 | slug?: string; |
| 44 | region?: string; |
| 45 | } |
| 46 | |
| 47 | export 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 | */ |
| 152 | export 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 | |
| 177 | export 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 | */ |
| 190 | export 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 | */ |
| 205 | export 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 | |
| 216 | export interface UpdateProjectInput { |
| 217 | name?: string; |
| 218 | slug?: string; |
| 219 | } |
| 220 | |
| 221 | export 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 | |
| 247 | export 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 | */ |
| 270 | export 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 | } |