From 0821393c1d9c1f996754d4be3331aa059d16dc4b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 22:06:01 -0500 Subject: [PATCH] feat(gateway): WorkspaceService + ProjectBootstrapService + TeamsService (P8-015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceService: path resolution, git init/clone, directory lifecycle (create/delete/exists), user and team root provisioning - ProjectBootstrapService: orchestrates DB record creation (via Brain) + workspace directory init in a single call - TeamsService: isMember, canAccessProject, findAll, findById, listMembers via Drizzle DB queries - WorkspaceController: POST /api/workspaces — auth-guarded project bootstrap endpoint - TeamsController: GET /api/teams, /:teamId, /:teamId/members, /:teamId/members/:userId - WorkspaceModule wired into AppModule - workspace.service.spec.ts: 5 unit tests for resolvePath (user, team, fallback, env var, default) Co-Authored-By: Claude Sonnet 4.6 --- apps/gateway/src/app.module.ts | 2 + .../workspace/project-bootstrap.service.ts | 63 ++++++++++ .../gateway/src/workspace/teams.controller.ts | 30 +++++ apps/gateway/src/workspace/teams.service.ts | 73 ++++++++++++ .../src/workspace/workspace.controller.ts | 30 +++++ .../gateway/src/workspace/workspace.module.ts | 13 ++ .../src/workspace/workspace.service.spec.ts | 79 +++++++++++++ .../src/workspace/workspace.service.ts | 111 ++++++++++++++++++ 8 files changed, 401 insertions(+) create mode 100644 apps/gateway/src/workspace/project-bootstrap.service.ts create mode 100644 apps/gateway/src/workspace/teams.controller.ts create mode 100644 apps/gateway/src/workspace/teams.service.ts create mode 100644 apps/gateway/src/workspace/workspace.controller.ts create mode 100644 apps/gateway/src/workspace/workspace.module.ts create mode 100644 apps/gateway/src/workspace/workspace.service.spec.ts create mode 100644 apps/gateway/src/workspace/workspace.service.ts diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index d50dcf4..44cd2be 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -21,6 +21,7 @@ import { CommandsModule } from './commands/commands.module.js'; import { PreferencesModule } from './preferences/preferences.module.js'; import { GCModule } from './gc/gc.module.js'; import { ReloadModule } from './reload/reload.module.js'; +import { WorkspaceModule } from './workspace/workspace.module.js'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ @@ -46,6 +47,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; CommandsModule, GCModule, ReloadModule, + WorkspaceModule, ], controllers: [HealthController], providers: [ diff --git a/apps/gateway/src/workspace/project-bootstrap.service.ts b/apps/gateway/src/workspace/project-bootstrap.service.ts new file mode 100644 index 0000000..c4afbae --- /dev/null +++ b/apps/gateway/src/workspace/project-bootstrap.service.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { Brain } from '@mosaic/brain'; +import { BRAIN } from '../brain/brain.tokens.js'; +import { WorkspaceService } from './workspace.service.js'; + +export interface BootstrapProjectParams { + name: string; + description?: string; + userId: string; + teamId?: string; + repoUrl?: string; +} + +export interface BootstrapProjectResult { + projectId: string; + workspacePath: string; +} + +@Injectable() +export class ProjectBootstrapService { + private readonly logger = new Logger(ProjectBootstrapService.name); + + constructor( + @Inject(BRAIN) private readonly brain: Brain, + private readonly workspace: WorkspaceService, + ) {} + + /** + * Bootstrap a new project: create DB record + workspace directory. + * Returns the created project with its workspace path. + */ + async bootstrap(params: BootstrapProjectParams): Promise { + const ownerType: 'user' | 'team' = params.teamId ? 'team' : 'user'; + + this.logger.log( + `Bootstrapping project "${params.name}" for ${ownerType} ${params.teamId ?? params.userId}`, + ); + + // 1. Create DB record + const project = await this.brain.projects.create({ + name: params.name, + description: params.description, + ownerId: params.userId, + teamId: params.teamId ?? null, + ownerType, + }); + + // 2. Create workspace directory + const workspacePath = await this.workspace.create( + { + id: project.id, + ownerType, + userId: params.userId, + teamId: params.teamId ?? null, + }, + params.repoUrl, + ); + + this.logger.log(`Project ${project.id} bootstrapped at ${workspacePath}`); + + return { projectId: project.id, workspacePath }; + } +} diff --git a/apps/gateway/src/workspace/teams.controller.ts b/apps/gateway/src/workspace/teams.controller.ts new file mode 100644 index 0000000..0046b78 --- /dev/null +++ b/apps/gateway/src/workspace/teams.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { TeamsService } from './teams.service.js'; + +@Controller('api/teams') +@UseGuards(AuthGuard) +export class TeamsController { + constructor(private readonly teams: TeamsService) {} + + @Get() + async list() { + return this.teams.findAll(); + } + + @Get(':teamId') + async findOne(@Param('teamId') teamId: string) { + return this.teams.findById(teamId); + } + + @Get(':teamId/members') + async listMembers(@Param('teamId') teamId: string) { + return this.teams.listMembers(teamId); + } + + @Get(':teamId/members/:userId') + async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) { + const isMember = await this.teams.isMember(teamId, userId); + return { isMember }; + } +} diff --git a/apps/gateway/src/workspace/teams.service.ts b/apps/gateway/src/workspace/teams.service.ts new file mode 100644 index 0000000..597e36e --- /dev/null +++ b/apps/gateway/src/workspace/teams.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { eq, and, type Db, teams, teamMembers, projects } from '@mosaic/db'; +import { DB } from '../database/database.module.js'; + +@Injectable() +export class TeamsService { + private readonly logger = new Logger(TeamsService.name); + + constructor(@Inject(DB) private readonly db: Db) {} + + /** + * Check if a user is a member of a team. + */ + async isMember(teamId: string, userId: string): Promise { + const rows = await this.db + .select({ id: teamMembers.id }) + .from(teamMembers) + .where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId))); + return rows.length > 0; + } + + /** + * Check project access for a user. + * - ownerType === 'user': project.ownerId must equal userId + * - ownerType === 'team': userId must be a member of project.teamId + */ + async canAccessProject(userId: string, projectId: string): Promise { + const rows = await this.db + .select({ + id: projects.id, + ownerType: projects.ownerType, + ownerId: projects.ownerId, + teamId: projects.teamId, + }) + .from(projects) + .where(eq(projects.id, projectId)); + + const project = rows[0]; + if (!project) return false; + + if (project.ownerType === 'user') { + return project.ownerId === userId; + } + + if (project.ownerType === 'team' && project.teamId) { + return this.isMember(project.teamId, userId); + } + + return false; + } + + /** + * List all teams (for admin/listing endpoints). + */ + async findAll() { + return this.db.select().from(teams); + } + + /** + * Find a team by ID. + */ + async findById(id: string) { + const rows = await this.db.select().from(teams).where(eq(teams.id, id)); + return rows[0]; + } + + /** + * List members of a team. + */ + async listMembers(teamId: string) { + return this.db.select().from(teamMembers).where(eq(teamMembers.teamId, teamId)); + } +} diff --git a/apps/gateway/src/workspace/workspace.controller.ts b/apps/gateway/src/workspace/workspace.controller.ts new file mode 100644 index 0000000..02bccdd --- /dev/null +++ b/apps/gateway/src/workspace/workspace.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { ProjectBootstrapService } from './project-bootstrap.service.js'; + +@Controller('api/workspaces') +@UseGuards(AuthGuard) +export class WorkspaceController { + constructor(private readonly bootstrap: ProjectBootstrapService) {} + + @Post() + async create( + @CurrentUser() user: { id: string }, + @Body() + body: { + name: string; + description?: string; + teamId?: string; + repoUrl?: string; + }, + ) { + return this.bootstrap.bootstrap({ + name: body.name, + description: body.description, + userId: user.id, + teamId: body.teamId, + repoUrl: body.repoUrl, + }); + } +} diff --git a/apps/gateway/src/workspace/workspace.module.ts b/apps/gateway/src/workspace/workspace.module.ts new file mode 100644 index 0000000..77531f1 --- /dev/null +++ b/apps/gateway/src/workspace/workspace.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { WorkspaceService } from './workspace.service.js'; +import { ProjectBootstrapService } from './project-bootstrap.service.js'; +import { TeamsService } from './teams.service.js'; +import { WorkspaceController } from './workspace.controller.js'; +import { TeamsController } from './teams.controller.js'; + +@Module({ + controllers: [WorkspaceController, TeamsController], + providers: [WorkspaceService, ProjectBootstrapService, TeamsService], + exports: [WorkspaceService, ProjectBootstrapService, TeamsService], +}) +export class WorkspaceModule {} diff --git a/apps/gateway/src/workspace/workspace.service.spec.ts b/apps/gateway/src/workspace/workspace.service.spec.ts new file mode 100644 index 0000000..c7de3c1 --- /dev/null +++ b/apps/gateway/src/workspace/workspace.service.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { WorkspaceService } from './workspace.service.js'; +import path from 'node:path'; + +describe('WorkspaceService', () => { + let service: WorkspaceService; + + beforeEach(() => { + service = new WorkspaceService(); + }); + + describe('resolvePath', () => { + it('resolves user workspace path', () => { + const result = service.resolvePath({ + id: 'proj1', + ownerType: 'user', + userId: 'user1', + teamId: null, + }); + expect(result).toContain(path.join('users', 'user1', 'proj1')); + }); + + it('resolves team workspace path', () => { + const result = service.resolvePath({ + id: 'proj1', + ownerType: 'team', + userId: 'user1', + teamId: 'team1', + }); + expect(result).toContain(path.join('teams', 'team1', 'proj1')); + }); + + it('falls back to user path when ownerType is team but teamId is null', () => { + const result = service.resolvePath({ + id: 'proj1', + ownerType: 'team', + userId: 'user1', + teamId: null, + }); + expect(result).toContain(path.join('users', 'user1', 'proj1')); + }); + + it('uses MOSAIC_ROOT env var as the base path', () => { + const originalRoot = process.env['MOSAIC_ROOT']; + process.env['MOSAIC_ROOT'] = '/custom/root'; + const customService = new WorkspaceService(); + const result = customService.resolvePath({ + id: 'proj1', + ownerType: 'user', + userId: 'user1', + teamId: null, + }); + expect(result).toMatch(/^\/custom\/root/); + // Restore + if (originalRoot === undefined) { + delete process.env['MOSAIC_ROOT']; + } else { + process.env['MOSAIC_ROOT'] = originalRoot; + } + }); + + it('defaults to /opt/mosaic when MOSAIC_ROOT is unset', () => { + const originalRoot = process.env['MOSAIC_ROOT']; + delete process.env['MOSAIC_ROOT']; + const defaultService = new WorkspaceService(); + const result = defaultService.resolvePath({ + id: 'proj2', + ownerType: 'user', + userId: 'user2', + teamId: null, + }); + expect(result).toMatch(/^\/opt\/mosaic/); + // Restore + if (originalRoot !== undefined) { + process.env['MOSAIC_ROOT'] = originalRoot; + } + }); + }); +}); diff --git a/apps/gateway/src/workspace/workspace.service.ts b/apps/gateway/src/workspace/workspace.service.ts new file mode 100644 index 0000000..5dc8ae4 --- /dev/null +++ b/apps/gateway/src/workspace/workspace.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export interface WorkspaceProject { + id: string; + ownerType: 'user' | 'team'; + userId: string; + teamId: string | null; +} + +@Injectable() +export class WorkspaceService { + private readonly logger = new Logger(WorkspaceService.name); + private readonly mosaicRoot: string; + + constructor() { + this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic'; + } + + /** + * Resolve the workspace path for a project. + * Solo: $MOSAIC_ROOT/.workspaces/users/// + * Team: $MOSAIC_ROOT/.workspaces/teams/// + */ + resolvePath(project: WorkspaceProject): string { + if (project.ownerType === 'team' && project.teamId) { + return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id); + } + return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id); + } + + /** + * Create a workspace directory and initialize it as a git repo. + * If repoUrl is provided, clone instead of init. + */ + async create(project: WorkspaceProject, repoUrl?: string): Promise { + const workspacePath = this.resolvePath(project); + + // Create directory + await fs.mkdir(workspacePath, { recursive: true }); + + if (repoUrl) { + // Clone existing repo + await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath }); + this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`); + } else { + // Init new git repo + await execFileAsync('git', ['init'], { cwd: workspacePath }); + await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], { + cwd: workspacePath, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Mosaic', + GIT_AUTHOR_EMAIL: 'mosaic@localhost', + GIT_COMMITTER_NAME: 'Mosaic', + GIT_COMMITTER_EMAIL: 'mosaic@localhost', + }, + }); + this.logger.log(`Initialized git workspace at ${workspacePath}`); + } + + return workspacePath; + } + + /** + * Delete a workspace directory recursively. + */ + async delete(project: WorkspaceProject): Promise { + const workspacePath = this.resolvePath(project); + try { + await fs.rm(workspacePath, { recursive: true, force: true }); + this.logger.log(`Deleted workspace at ${workspacePath}`); + } catch (err) { + this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`); + } + } + + /** + * Check whether the workspace directory exists. + */ + async exists(project: WorkspaceProject): Promise { + const workspacePath = this.resolvePath(project); + try { + await fs.access(workspacePath); + return true; + } catch { + return false; + } + } + + /** + * Create the base user workspace directory (call on user registration). + */ + async createUserRoot(userId: string): Promise { + const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId); + await fs.mkdir(userRoot, { recursive: true }); + } + + /** + * Create the base team workspace directory (call on team creation). + */ + async createTeamRoot(teamId: string): Promise { + const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId); + await fs.mkdir(teamRoot, { recursive: true }); + } +}