Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
372 lines
11 KiB
JavaScript
372 lines
11 KiB
JavaScript
// API v3: Projects Routes
|
|
import express from 'express';
|
|
import { dbAll } from '../../database/connection.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import { success, created, notFound, badRequest, forbidden, error } from '../../utils/response.js';
|
|
|
|
/**
|
|
* @swagger
|
|
* components:
|
|
* schemas:
|
|
* Project:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: integer
|
|
* space_id:
|
|
* type: integer
|
|
* owner_id:
|
|
* type: integer
|
|
* name:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* nullable: true
|
|
* icon:
|
|
* type: string
|
|
* type:
|
|
* type: string
|
|
* enum: [project, system_data, ai_agents, personal, team]
|
|
* created_at:
|
|
* type: string
|
|
* format: date-time
|
|
* updated_at:
|
|
* type: string
|
|
* format: date-time
|
|
*/
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* @swagger
|
|
* /projects:
|
|
* get:
|
|
* tags: [Projects]
|
|
* summary: Get all projects
|
|
* description: Get all projects for authenticated user, optionally filtered by space_id
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: space_id
|
|
* schema:
|
|
* type: integer
|
|
* description: Filter by space ID
|
|
* responses:
|
|
* 200:
|
|
* description: List of projects
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* success:
|
|
* type: boolean
|
|
* data:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/Project'
|
|
* 401:
|
|
* description: Unauthorized
|
|
*/
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
const userRole = req.user.role;
|
|
const { space_id } = req.query;
|
|
|
|
// Admin/owner can see all projects EXCEPT personal spaces of other users
|
|
// Personal spaces are always private and only visible to owner
|
|
// Regular users can see: own projects + projects in spaces they have access to
|
|
let whereClause;
|
|
let params;
|
|
|
|
if (userRole === 'admin' || userRole === 'owner') {
|
|
// System admin/owner: see all projects, but only own personal space
|
|
whereClause = '(s.type != ? OR (s.type = ? AND p.owner_id = ?))';
|
|
params = ['personal', 'personal', userId];
|
|
} else {
|
|
// Regular user: own projects + projects in accessible spaces
|
|
// Use SpaceService to resolve space-level access (supports table-based access control)
|
|
const { getSpacesByUser } = await import('../../services/SpaceService.js');
|
|
const accessibleSpaces = await getSpacesByUser(userId, userRole);
|
|
// getSpacesByUser already handles personal space gating via checkUserSpaceAccess
|
|
// (explicit grants in user_access_permissions are checked before the personal-space gate)
|
|
const accessibleSpaceIds = accessibleSpaces.map(s => s.id);
|
|
|
|
if (accessibleSpaceIds.length > 0) {
|
|
const placeholders = accessibleSpaceIds.map(() => '?').join(',');
|
|
whereClause = `(p.owner_id = ? OR p.space_id IN (${placeholders}))`;
|
|
params = [userId, ...accessibleSpaceIds];
|
|
} else {
|
|
whereClause = 'p.owner_id = ?';
|
|
params = [userId];
|
|
}
|
|
}
|
|
|
|
// Add space_id filter if provided
|
|
if (space_id) {
|
|
whereClause += ' AND p.space_id = ?';
|
|
params.push(space_id);
|
|
}
|
|
|
|
const projects = await dbAll(`
|
|
SELECT
|
|
p.*,
|
|
s.name as space_name,
|
|
s.type as space_type
|
|
FROM projects p
|
|
LEFT JOIN spaces s ON p.space_id = s.id
|
|
WHERE ${whereClause}
|
|
ORDER BY s.type ASC, p.created_at DESC
|
|
`, params);
|
|
|
|
success(res, projects);
|
|
} catch (err) {
|
|
apiLogger.error('Error fetching projects:', err);
|
|
error(res, 'FETCH_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /projects:
|
|
* post:
|
|
* tags: [Projects]
|
|
* summary: Create new project
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [name]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* icon:
|
|
* type: string
|
|
* space_id:
|
|
* type: integer
|
|
* type:
|
|
* type: string
|
|
* enum: [project, system_data, ai_agents, personal, team]
|
|
* responses:
|
|
* 201:
|
|
* description: Project created
|
|
* 400:
|
|
* description: Validation error
|
|
*/
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const { name, description, icon, logo, space_id, type } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
// Validation
|
|
if (!name || !name.trim()) {
|
|
return badRequest(res, 'name is required');
|
|
}
|
|
|
|
// If no space_id provided, use Personal Space
|
|
let finalSpaceId = space_id;
|
|
if (!finalSpaceId) {
|
|
const [personalSpace] = await dbAll(
|
|
"SELECT id FROM spaces WHERE owner_id = ? AND type = 'personal' LIMIT 1",
|
|
[userId]
|
|
);
|
|
finalSpaceId = personalSpace?.id;
|
|
}
|
|
|
|
if (!finalSpaceId) {
|
|
return badRequest(res, 'No space available for project creation');
|
|
}
|
|
|
|
// Allowed project types
|
|
const allowedTypes = ['project', 'system_data', 'ai_agents', 'personal', 'team'];
|
|
const projectType = allowedTypes.includes(type) ? type : 'project';
|
|
|
|
const { dbRun } = await import('../../database/connection.js');
|
|
const result = await dbRun(
|
|
`INSERT INTO projects (space_id, owner_id, name, description, icon, type, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
[finalSpaceId, userId, name.trim(), description?.trim() || null, icon || logo || '📁', projectType]
|
|
);
|
|
|
|
// Fetch created project
|
|
const [newProject] = await dbAll('SELECT * FROM projects WHERE id = ?', [result.lastID]);
|
|
|
|
created(res, newProject);
|
|
} catch (err) {
|
|
apiLogger.error('Error creating project:', err);
|
|
error(res, 'CREATE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /projects/{id}:
|
|
* put:
|
|
* tags: [Projects]
|
|
* summary: Update project
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* icon:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Project updated
|
|
* 403:
|
|
* description: Forbidden
|
|
* 404:
|
|
* description: Project not found
|
|
*/
|
|
router.put('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { name, description, icon, access_control, theme_primary, theme_secondary, theme_tertiary, is_public } = req.body;
|
|
const userId = req.user.id;
|
|
const userRole = req.user.role;
|
|
|
|
// Check if project exists and user has permission
|
|
const [project] = await dbAll('SELECT * FROM projects WHERE id = ?', [id]);
|
|
|
|
if (!project) {
|
|
return notFound(res, 'Project');
|
|
}
|
|
|
|
// Only owner or admin can update
|
|
if (project.owner_id !== userId && userRole !== 'admin' && userRole !== 'owner') {
|
|
return forbidden(res, 'You do not have permission to update this project');
|
|
}
|
|
|
|
// Build update query
|
|
const updates = [];
|
|
const params = [];
|
|
|
|
if (name !== undefined) {
|
|
updates.push('name = ?');
|
|
params.push(name);
|
|
}
|
|
if (description !== undefined) {
|
|
updates.push('description = ?');
|
|
params.push(description);
|
|
}
|
|
if (icon !== undefined) {
|
|
updates.push('icon = ?');
|
|
params.push(icon);
|
|
}
|
|
if (access_control !== undefined) {
|
|
updates.push('access_control = ?');
|
|
params.push(access_control ? JSON.stringify(access_control) : null);
|
|
}
|
|
if (theme_primary !== undefined) {
|
|
updates.push('theme_primary = ?');
|
|
params.push(theme_primary);
|
|
}
|
|
if (theme_secondary !== undefined) {
|
|
updates.push('theme_secondary = ?');
|
|
params.push(theme_secondary);
|
|
}
|
|
if (theme_tertiary !== undefined) {
|
|
updates.push('theme_tertiary = ?');
|
|
params.push(theme_tertiary);
|
|
}
|
|
if (is_public !== undefined) {
|
|
updates.push('is_public = ?');
|
|
params.push(Boolean(is_public));
|
|
}
|
|
|
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
|
params.push(id);
|
|
|
|
const { dbRun } = await import('../../database/connection.js');
|
|
await dbRun(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`, params);
|
|
|
|
// Fetch updated project
|
|
const [updatedProject] = await dbAll('SELECT * FROM projects WHERE id = ?', [id]);
|
|
|
|
success(res, updatedProject);
|
|
} catch (err) {
|
|
apiLogger.error('Error updating project:', err);
|
|
error(res, 'UPDATE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @swagger
|
|
* /projects/{id}:
|
|
* delete:
|
|
* tags: [Projects]
|
|
* summary: Delete project
|
|
* description: Delete a project (only owner or admin). Cannot delete system projects.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Project deleted
|
|
* 403:
|
|
* description: Forbidden
|
|
* 404:
|
|
* description: Project not found
|
|
*/
|
|
router.delete('/:id', async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
const userRole = req.user.role;
|
|
const projectId = parseInt(req.params.id);
|
|
|
|
// Get project
|
|
const projects = await dbAll('SELECT * FROM projects WHERE id = ?', [projectId]);
|
|
if (projects.length === 0) {
|
|
return notFound(res, 'Project');
|
|
}
|
|
|
|
const project = projects[0];
|
|
|
|
// Only owner or admin can delete
|
|
if (project.owner_id !== userId && userRole !== 'admin' && userRole !== 'owner') {
|
|
return forbidden(res, 'You do not have permission to delete this project');
|
|
}
|
|
|
|
// Prevent deleting system projects
|
|
if (project.type === 'admin_owner_space' || project.type === 'personal_space') {
|
|
return forbidden(res, 'Cannot delete system projects');
|
|
}
|
|
|
|
const { dbRun } = await import('../../database/connection.js');
|
|
await dbRun('DELETE FROM projects WHERE id = ?', [projectId]);
|
|
|
|
success(res, { message: 'Project deleted successfully' });
|
|
} catch (err) {
|
|
apiLogger.error('Error deleting project:', err);
|
|
error(res, 'DELETE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|