diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63493058..17e51494 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,16 +20,21 @@ datasource db { // model Account { - id String @id @default(cuid()) - publicKey String @unique - seq Int @default(0) - feedSeq BigInt @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - settings String? - settingsVersion Int @default(0) - githubUserId String? @unique - githubUser GithubUser? @relation(fields: [githubUserId], references: [id]) + id String @id @default(cuid()) + publicKey String @unique + seq Int @default(0) + feedSeq BigInt @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + settings String? + settingsVersion Int @default(0) + githubUserId String? @unique + githubUser GithubUser? @relation(fields: [githubUserId], references: [id]) + + // SuperTokens authentication + supertokensUserId String? @unique + email String? + authProvider String? // 'google', 'emailpassword', 'github' // Profile firstName String? @@ -52,6 +57,12 @@ model Account { AccessKey AccessKey[] UserFeedItem UserFeedItem[] UserKVStore UserKVStore[] + Project Project[] + Room Room[] + MachineToken MachineToken[] + ContainerLaunch ContainerLaunch[] + RoomMessage RoomMessage[] + ProjectNote ProjectNote[] } model TerminalAuthRequest { @@ -108,6 +119,7 @@ model Session { messages SessionMessage[] usageReports UsageReport[] accessKeys AccessKey[] + roomSessions RoomSession[] @@unique([accountId, tag]) @@index([accountId, updatedAt(sort: Desc)]) @@ -361,3 +373,245 @@ model UserKVStore { @@unique([accountId, key]) @@index([accountId]) } + +// +// Projects & Rooms +// + +model Project { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + // Tree structure (materialized path) + parentId String? + parent Project? @relation("ProjectHierarchy", fields: [parentId], references: [id], onDelete: Cascade) + children Project[] @relation("ProjectHierarchy") + path String @default("/") // "/rootId/childId/grandchildId/" + depth Int @default(0) + + // Metadata + name String + description String? + /// [ProjectMetadata] + metadata Json? + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + rooms Room[] + ContainerLaunch ContainerLaunch[] + projectNotes ProjectNote[] + + @@unique([accountId, parentId, name]) + @@index([accountId, path]) + @@index([accountId]) +} + +model Room { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + name String + description String? + /// [RoomMetadata] + metadata Json? // Future: workspace config, shared resources + status RoomStatus @default(active) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + roomSessions RoomSession[] + roomContainers RoomContainer[] + messages RoomMessage[] + + @@unique([projectId, name]) + @@index([accountId]) + @@index([projectId]) +} + +model RoomSession { + id String @id @default(cuid()) + roomId String + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + role RoomSessionRole @default(member) + joinedAt DateTime @default(now()) + + @@unique([roomId, sessionId]) + @@index([roomId]) + @@index([sessionId]) +} + +enum RoomStatus { + active + archived + locked +} + +enum RoomSessionRole { + primary + member + observer +} + +// +// Machine Tokens (for container/daemon authentication) +// + +model MachineToken { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + name String // User-friendly name: "My Docker Agent" + token String @unique // The bearer token value (mkt_...) + lastUsedAt DateTime? + expiresAt DateTime? // Optional expiration + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ContainerLaunch ContainerLaunch[] + + @@index([accountId]) + @@index([token]) +} + +// +// Container Launch Orchestration (WAP Integration) +// + +enum ContainerStatus { + pending + creating + starting + running + stopping + stopped + removing + removed + failed + error + offline +} + +model ContainerLaunch { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + name String // User-friendly name: "My Dev Agent" + + // WAP / Docker identifiers + containerName String? // Docker container name (forge--) + wapContainerId String? // WAP's container ID + image String // Docker image + tag String @default("latest") + config Json // Full container config (env, ports, volumes, resources, etc.) + + // AI-Forge agent identity + containerType String @default("forge-agent") // Identifies AI-Forge-compatible containers + agentType String? // claude-daemon, claude-flow-agent, dev-agent, custom + projectRole String? // Developer, reviewer, tester, orchestrator, etc. + agentVersion String? // Version string of the agent software + + // Project association + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + + // Status + status ContainerStatus @default(pending) + statusMessage String? + lastSeenAt DateTime? // Last time WAP sync confirmed running + + // Machine token + machine linkage + machineId String? + machineTokenId String? + machineToken MachineToken? @relation(fields: [machineTokenId], references: [id], onDelete: SetNull) + + // Lifecycle timestamps + startedAt DateTime? + stoppedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Room associations + roomContainers RoomContainer[] + + @@index([accountId]) + @@index([accountId, status]) + @@index([accountId, projectId]) + @@index([wapContainerId]) + @@index([machineId]) + @@index([containerType]) +} + +model RoomContainer { + id String @id @default(cuid()) + roomId String + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + containerId String + container ContainerLaunch @relation(fields: [containerId], references: [id], onDelete: Cascade) + role RoomSessionRole @default(member) + assignedAt DateTime @default(now()) + + @@unique([roomId, containerId]) + @@index([roomId]) + @@index([containerId]) +} + +model RoomMessage { + id String @id @default(cuid()) + roomId String + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + accountId String + account Account @relation(fields: [accountId], references: [id]) + content String + type String @default("user") // "user" | "assistant" | "system" + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([roomId, createdAt(sort: Desc)]) + @@index([accountId]) +} + +model ProjectNote { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + accountId String + account Account @relation(fields: [accountId], references: [id]) + sourceAgentType String + targetAgentType String + title String + content String + status String @default("pending") // "pending" | "acknowledged" | "completed" + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId, targetAgentType]) + @@index([projectId, status]) + @@index([accountId]) +} + +model WapTemplateCache { + id String @id @default(cuid()) + wapTemplateId String @unique // ID from WAP's application/template system + name String + description String? + image String + tag String @default("latest") + config Json // Default container config from WAP + category String? // Grouping category + isForgeReady Boolean @default(false) // Pre-configured for ai-forge-client + lastSyncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isForgeReady]) + @@index([category]) +} diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index e6db9e84..b5b0b39a 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -21,22 +21,52 @@ import { enableAuthentication } from "./utils/enableAuthentication"; import { userRoutes } from "./routes/userRoutes"; import { feedRoutes } from "./routes/feedRoutes"; import { kvRoutes } from "./routes/kvRoutes"; +import { projectRoutes } from "./routes/projectRoutes"; +import { roomRoutes } from "./routes/roomRoutes"; +import { machineTokenRoutes } from "./routes/machineTokenRoutes"; +import { containerRoutes } from "./routes/containerRoutes"; +import { roomMessageRoutes } from "./routes/roomMessageRoutes"; +import { projectNoteRoutes } from "./routes/projectNoteRoutes"; +import { initSuperTokens } from "@/app/auth/supertokens"; +import supertokens from "supertokens-node"; +import { plugin as supertokensPlugin, errorHandler as supertokensErrorHandler } from "supertokens-node/framework/fastify"; export async function startApi() { // Configure log('Starting API...'); + // Initialize SuperTokens + initSuperTokens(); + // Start API const app = fastify({ loggerInstance: logger, bodyLimit: 1024 * 1024 * 100, // 100MB }); + + // CORS configuration with SuperTokens headers app.register(import('@fastify/cors'), { - origin: '*', - allowedHeaders: '*', - methods: ['GET', 'POST', 'DELETE'] + origin: [ + process.env.WEBSITE_DOMAIN || 'http://15.204.94.200:5175', + 'http://localhost:5175', + 'http://localhost:5173', + ], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + ...supertokens.getAllCORSHeaders(), + ], + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'], + credentials: true, }); + + // Register SuperTokens plugin (handles /auth/* routes) + await app.register(supertokensPlugin); + + // SuperTokens error handler + app.setErrorHandler(supertokensErrorHandler()); + app.get('/', function (request, reply) { reply.send('Welcome to Happy Server!'); }); @@ -66,8 +96,14 @@ export async function startApi() { userRoutes(typed); feedRoutes(typed); kvRoutes(typed); + projectRoutes(typed); + roomRoutes(typed); + machineTokenRoutes(typed); + containerRoutes(typed); + roomMessageRoutes(typed); + projectNoteRoutes(typed); - // Start HTTP + // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; await app.listen({ port, host: '0.0.0.0' }); onShutdown('api', async () => { diff --git a/sources/app/api/routes/containerRoutes.ts b/sources/app/api/routes/containerRoutes.ts new file mode 100644 index 00000000..ddd537c7 --- /dev/null +++ b/sources/app/api/routes/containerRoutes.ts @@ -0,0 +1,940 @@ +import { eventRouter } from "@/app/events/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { log, warn } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import { auth } from "@/app/auth/auth"; +import { wapClient } from "@/app/wap/wapClient"; +import { syncContainers, syncTemplates } from "@/app/wap/wapSync"; +import { + buildNewContainerLaunchUpdate, + buildContainerStatusUpdate, + buildDeleteContainerLaunchUpdate, + buildContainerRoomAssignedUpdate, + buildContainerRoomRemovedUpdate, +} from "@/app/events/containerEvents"; +import { randomBytes } from "crypto"; + +function generateMachineId(): string { + return randomBytes(16).toString('hex'); +} + +function generateContainerName(name: string, id: string): string { + // Sanitize name for Docker: lowercase, alphanumeric + hyphens + const sanitized = name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const shortId = id.slice(0, 8); + return `forge-${sanitized}-${shortId}`; +} + +export function containerRoutes(app: Fastify) { + + // ========================================== + // WAP Template Endpoints (read-only, from cache) + // ========================================== + + // List available WAP templates + app.get('/v1/wap-templates', { + preHandler: app.authenticate, + }, async (request, reply) => { + const templates = await db.wapTemplateCache.findMany({ + orderBy: [{ isForgeReady: 'desc' }, { name: 'asc' }], + }); + + return reply.send({ + templates: templates.map(t => ({ + id: t.id, + wapTemplateId: t.wapTemplateId, + name: t.name, + description: t.description, + image: t.image, + tag: t.tag, + config: t.config, + category: t.category, + isForgeReady: t.isForgeReady, + lastSyncedAt: t.lastSyncedAt.getTime(), + })) + }); + }); + + // Get WAP template details + app.get('/v1/wap-templates/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const { id } = request.params; + + const template = await db.wapTemplateCache.findUnique({ where: { id } }); + if (!template) { + return reply.code(404).send({ error: 'Template not found' }); + } + + return reply.send({ + template: { + id: template.id, + wapTemplateId: template.wapTemplateId, + name: template.name, + description: template.description, + image: template.image, + tag: template.tag, + config: template.config, + category: template.category, + isForgeReady: template.isForgeReady, + lastSyncedAt: template.lastSyncedAt.getTime(), + } + }); + }); + + // Trigger manual template sync + app.post('/v1/wap-templates/sync', { + preHandler: app.authenticate, + }, async (_request, reply) => { + await syncTemplates(); + return reply.send({ success: true }); + }); + + // ========================================== + // Container Launch & Lifecycle Endpoints + // ========================================== + + // Launch a new container + app.post('/v1/containers/launch', { + preHandler: app.authenticate, + schema: { + body: z.object({ + name: z.string().min(1).max(200), + projectId: z.string(), + // Image source: either wapTemplateId or direct image + wapTemplateId: z.string().optional(), + image: z.string().optional(), + tag: z.string().optional(), + // Agent identity + containerType: z.string().default('forge-agent'), + agentType: z.string().optional(), + projectRole: z.string().optional(), + agentVersion: z.string().optional(), + // Container config overrides + config: z.object({ + env: z.record(z.string()).optional(), + ports: z.array(z.object({ + containerPort: z.number(), + hostPort: z.number().optional(), + protocol: z.string().default('tcp'), + })).optional(), + volumes: z.array(z.string()).optional(), + resources: z.object({ + cpuLimit: z.number().optional(), + memoryLimit: z.string().optional(), + }).optional(), + restartPolicy: z.string().optional(), + }).optional(), + // Room assignment + roomIds: z.array(z.string()).optional(), + }) + } + }, async (request, reply) => { + const userId = request.userId; + const body = request.body; + + // Validate project exists and belongs to user + const project = await db.project.findFirst({ + where: { id: body.projectId, accountId: userId }, + include: { rooms: { select: { id: true }, take: 1 } } + }); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Resolve image from template or direct input + let image: string; + let tag: string; + let templateConfig: any = {}; + + if (body.wapTemplateId) { + const template = await db.wapTemplateCache.findFirst({ + where: { wapTemplateId: body.wapTemplateId } + }); + if (!template) { + return reply.code(404).send({ error: 'WAP template not found' }); + } + image = template.image; + tag = body.tag || template.tag; + templateConfig = template.config || {}; + } else if (body.image) { + image = body.image; + tag = body.tag || 'latest'; + } else { + return reply.code(400).send({ error: 'Either wapTemplateId or image is required' }); + } + + // 1. Create machine token for the container + const machineId = generateMachineId(); + const bearerToken = await auth.createToken(userId, { + type: 'machine', + tokenId: `mkt_container_${machineId}`, + name: `Container: ${body.name}`, + }); + + const machineToken = await db.machineToken.create({ + data: { + accountId: userId, + name: `Container: ${body.name}`, + token: bearerToken, + } + }); + + // 2. Create ContainerLaunch record + const containerName = generateContainerName(body.name, machineId); + const forgeServerUrl = process.env.FORGE_SERVER_PUBLIC_URL || process.env.WEBSITE_DOMAIN || 'http://localhost:3005'; + + const launch = await db.containerLaunch.create({ + data: { + accountId: userId, + name: body.name, + containerName, + image, + tag, + config: body.config || {}, + containerType: body.containerType, + agentType: body.agentType || null, + projectRole: body.projectRole || null, + agentVersion: body.agentVersion || null, + projectId: body.projectId, + status: 'pending', + machineId, + machineTokenId: machineToken.id, + } + }); + + // 3. Build WAP create request + // Merge env vars: template defaults + user config + forge injected + const userEnv = body.config?.env || {}; + const templateEnv: Record = {}; + if (Array.isArray(templateConfig.env)) { + for (const e of templateConfig.env) { + const [k, ...vParts] = (e as string).split('='); + if (k) templateEnv[k] = vParts.join('='); + } + } + + const mergedEnv: Record = { + ...templateEnv, + ...userEnv, + // Forge injected env vars (override everything) + FORGE_MACHINE_TOKEN: bearerToken, + FORGE_SERVER_URL: forgeServerUrl, + FORGE_SOCKET_URL: forgeServerUrl, + FORGE_MACHINE_ID: machineId, + // Backwards compat + HAPPY_MACHINE_TOKEN: bearerToken, + HAPPY_SERVER_URL: forgeServerUrl, + HAPPY_SOCKET_URL: forgeServerUrl, + HAPPY_MACHINE_ID: machineId, + }; + if (process.env.ANTHROPIC_API_KEY) { + mergedEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + } + if (body.agentType) { + mergedEnv.AGENT_TYPE = body.agentType; + } + if (body.projectRole) { + mergedEnv.AGENT_USE_CASE = body.projectRole; + } + + const envArray = Object.entries(mergedEnv).map(([k, v]) => `${k}=${v}`); + + const labels: Record = { + 'forge.launch.id': launch.id, + 'forge.project.id': body.projectId, + 'forge.container.type': body.containerType, + }; + if (body.agentType) { + labels['forge.agent.type'] = body.agentType; + } + + // 4. Call WAP to create container + try { + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { status: 'creating' } + }); + + const wapResponse = await wapClient.createContainer({ + name: containerName, + image: `${image}:${tag}`, + env: envArray, + network: 't3_proxy', + labels, + restartPolicy: body.config?.restartPolicy || templateConfig.restartPolicy || 'unless-stopped', + resources: body.config?.resources || templateConfig.resources, + ports: body.config?.ports || templateConfig.ports, + volumes: body.config?.volumes || templateConfig.volumes, + }); + + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { + wapContainerId: wapResponse.id, + status: 'creating', + } + }); + + // 5. Start the container + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { status: 'starting' } + }); + + await wapClient.startContainer(wapResponse.id); + + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { + status: 'starting', + startedAt: new Date(), + } + }); + } catch (err) { + // Mark as failed + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { + status: 'failed', + statusMessage: `${err}`, + } + }); + warn({ module: 'container-launch', launchId: launch.id }, `Container launch failed: ${err}`); + // Still return the record so user can see the error + } + + // 6. Auto-assign to rooms + const roomIds = body.roomIds || (project.rooms.length > 0 ? [project.rooms[0].id] : []); + for (const roomId of roomIds) { + try { + await db.roomContainer.create({ + data: { + roomId, + containerId: launch.id, + role: 'member', + } + }); + } catch { + // Ignore duplicate or invalid room + } + } + + // 7. Emit new-container-launch event + const updatedLaunch = await db.containerLaunch.findUnique({ where: { id: launch.id } }); + if (updatedLaunch) { + const updSeq = await allocateUserSeq(userId); + const payload = buildNewContainerLaunchUpdate(updatedLaunch, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload, + recipientFilter: { type: 'user-scoped-only' } + }); + } + + log({ module: 'container-launch', launchId: launch.id, userId }, `Launched container: ${body.name}`); + + return reply.send({ + launch: { + id: launch.id, + name: body.name, + containerName, + image, + tag, + status: updatedLaunch?.status || launch.status, + statusMessage: updatedLaunch?.statusMessage || null, + containerType: body.containerType, + agentType: body.agentType || null, + projectRole: body.projectRole || null, + agentVersion: body.agentVersion || null, + projectId: body.projectId, + wapContainerId: updatedLaunch?.wapContainerId || null, + machineId, + machineTokenId: machineToken.id, + // Return token value ONCE + machineTokenValue: bearerToken, + createdAt: launch.createdAt.getTime(), + } + }); + }); + + // List user's containers + app.get('/v1/containers', { + preHandler: app.authenticate, + schema: { + querystring: z.object({ + status: z.string().optional(), + projectId: z.string().optional(), + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const query = request.query as { status?: string; projectId?: string } | undefined; + + const where: any = { accountId: userId }; + if (query?.status) { + where.status = query.status; + } + if (query?.projectId) { + where.projectId = query.projectId; + } + + const containers = await db.containerLaunch.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + roomContainers: { + select: { roomId: true, role: true, assignedAt: true } + } + } + }); + + return reply.send({ + containers: containers.map(c => ({ + id: c.id, + name: c.name, + containerName: c.containerName, + image: c.image, + tag: c.tag, + containerType: c.containerType, + agentType: c.agentType, + projectRole: c.projectRole, + agentVersion: c.agentVersion, + projectId: c.projectId, + status: c.status, + statusMessage: c.statusMessage, + machineId: c.machineId, + wapContainerId: c.wapContainerId, + lastSeenAt: c.lastSeenAt?.getTime() || null, + startedAt: c.startedAt?.getTime() || null, + stoppedAt: c.stoppedAt?.getTime() || null, + createdAt: c.createdAt.getTime(), + updatedAt: c.updatedAt.getTime(), + rooms: c.roomContainers.map(rc => ({ + roomId: rc.roomId, + role: rc.role, + assignedAt: rc.assignedAt.getTime(), + })), + })) + }); + }); + + // Get container details + app.get('/v1/containers/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId }, + include: { + roomContainers: { + include: { + room: { select: { id: true, name: true, projectId: true } } + } + }, + project: { select: { id: true, name: true } }, + } + }); + + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + + return reply.send({ + container: { + id: container.id, + name: container.name, + containerName: container.containerName, + image: container.image, + tag: container.tag, + config: container.config, + containerType: container.containerType, + agentType: container.agentType, + projectRole: container.projectRole, + agentVersion: container.agentVersion, + projectId: container.projectId, + project: container.project ? { id: container.project.id, name: container.project.name } : null, + status: container.status, + statusMessage: container.statusMessage, + machineId: container.machineId, + wapContainerId: container.wapContainerId, + lastSeenAt: container.lastSeenAt?.getTime() || null, + startedAt: container.startedAt?.getTime() || null, + stoppedAt: container.stoppedAt?.getTime() || null, + createdAt: container.createdAt.getTime(), + updatedAt: container.updatedAt.getTime(), + rooms: container.roomContainers.map(rc => ({ + roomId: rc.room.id, + roomName: rc.room.name, + role: rc.role, + assignedAt: rc.assignedAt.getTime(), + })), + } + }); + }); + + // Update container metadata + app.patch('/v1/containers/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }), + body: z.object({ + name: z.string().min(1).max(200).optional(), + agentType: z.string().optional(), + projectRole: z.string().optional(), + agentVersion: z.string().optional(), + containerType: z.string().optional(), + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const body = request.body; + + const existing = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!existing) { + return reply.code(404).send({ error: 'Container not found' }); + } + + const updated = await db.containerLaunch.update({ + where: { id }, + data: { + name: body.name ?? undefined, + agentType: body.agentType !== undefined ? body.agentType : undefined, + projectRole: body.projectRole !== undefined ? body.projectRole : undefined, + agentVersion: body.agentVersion !== undefined ? body.agentVersion : undefined, + containerType: body.containerType ?? undefined, + } + }); + + log({ module: 'container-update', launchId: id, userId }, 'Container metadata updated'); + + return reply.send({ + container: { + id: updated.id, + name: updated.name, + agentType: updated.agentType, + projectRole: updated.projectRole, + agentVersion: updated.agentVersion, + containerType: updated.containerType, + updatedAt: updated.updatedAt.getTime(), + } + }); + }); + + // Stop container + app.post('/v1/containers/:id/stop', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + if (!container.wapContainerId) { + return reply.code(400).send({ error: 'Container has no WAP ID (not yet created)' }); + } + + await db.containerLaunch.update({ + where: { id }, + data: { status: 'stopping' } + }); + + try { + await wapClient.stopContainer(container.wapContainerId); + await db.containerLaunch.update({ + where: { id }, + data: { status: 'stopped', stoppedAt: new Date() } + }); + } catch (err) { + await db.containerLaunch.update({ + where: { id }, + data: { status: 'error', statusMessage: `Stop failed: ${err}` } + }); + } + + const updSeq = await allocateUserSeq(userId); + const updatedContainer = await db.containerLaunch.findUnique({ where: { id } }); + const payload = buildContainerStatusUpdate( + id, updatedContainer!.status, updatedContainer!.statusMessage || null, + container.wapContainerId, container.machineId, + updSeq, randomKeyNaked(12) + ); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + + return reply.send({ success: true, status: updatedContainer!.status }); + }); + + // Start stopped container + app.post('/v1/containers/:id/start', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + if (!container.wapContainerId) { + return reply.code(400).send({ error: 'Container has no WAP ID' }); + } + + await db.containerLaunch.update({ + where: { id }, + data: { status: 'starting' } + }); + + try { + await wapClient.startContainer(container.wapContainerId); + await db.containerLaunch.update({ + where: { id }, + data: { status: 'starting', startedAt: new Date() } + }); + } catch (err) { + await db.containerLaunch.update({ + where: { id }, + data: { status: 'error', statusMessage: `Start failed: ${err}` } + }); + } + + const updSeq = await allocateUserSeq(userId); + const updatedContainer = await db.containerLaunch.findUnique({ where: { id } }); + const payload = buildContainerStatusUpdate( + id, updatedContainer!.status, updatedContainer!.statusMessage || null, + container.wapContainerId, container.machineId, + updSeq, randomKeyNaked(12) + ); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + + return reply.send({ success: true, status: updatedContainer!.status }); + }); + + // Restart container + app.post('/v1/containers/:id/restart', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + if (!container.wapContainerId) { + return reply.code(400).send({ error: 'Container has no WAP ID' }); + } + + try { + await wapClient.restartContainer(container.wapContainerId); + await db.containerLaunch.update({ + where: { id }, + data: { status: 'starting', startedAt: new Date() } + }); + } catch (err) { + await db.containerLaunch.update({ + where: { id }, + data: { status: 'error', statusMessage: `Restart failed: ${err}` } + }); + } + + const updSeq = await allocateUserSeq(userId); + const updatedContainer = await db.containerLaunch.findUnique({ where: { id } }); + const payload = buildContainerStatusUpdate( + id, updatedContainer!.status, updatedContainer!.statusMessage || null, + container.wapContainerId, container.machineId, + updSeq, randomKeyNaked(12) + ); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + + return reply.send({ success: true, status: updatedContainer!.status }); + }); + + // Delete container + revoke token + app.delete('/v1/containers/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId }, + include: { machineToken: true } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + + // Try to remove from WAP if it has a WAP ID + if (container.wapContainerId) { + try { + await wapClient.stopContainer(container.wapContainerId); + } catch { + // Ignore stop errors + } + try { + await wapClient.removeContainer(container.wapContainerId); + } catch { + // Ignore remove errors + } + } + + // Revoke machine token + if (container.machineToken) { + auth.invalidateToken(container.machineToken.token); + await db.machineToken.delete({ where: { id: container.machineToken.id } }); + } + + // Delete container record (cascades to RoomContainer) + await db.containerLaunch.delete({ where: { id } }); + + // Emit delete event + const updSeq = await allocateUserSeq(userId); + const payload = buildDeleteContainerLaunchUpdate(id, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + + log({ module: 'container-delete', launchId: id, userId }, 'Container deleted'); + + return reply.send({ success: true }); + }); + + // Trigger manual container sync + app.post('/v1/containers/sync', { + preHandler: app.authenticate, + }, async (_request, reply) => { + await syncContainers(); + return reply.send({ success: true }); + }); + + // ========================================== + // Room Assignment Endpoints + // ========================================== + + // Assign container to rooms + app.post('/v1/containers/:id/rooms', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }), + body: z.object({ + roomIds: z.array(z.string()).min(1), + role: z.enum(['primary', 'member', 'observer']).optional(), + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { roomIds, role } = request.body; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + + const assigned: Array<{ roomId: string; role: string }> = []; + + for (const roomId of roomIds) { + // Verify room belongs to user + const room = await db.room.findFirst({ + where: { id: roomId, accountId: userId } + }); + if (!room) continue; + + try { + await db.roomContainer.create({ + data: { + roomId, + containerId: id, + role: role || 'member', + } + }); + assigned.push({ roomId, role: role || 'member' }); + + // Emit room assignment event + const updSeq = await allocateUserSeq(userId); + const payload = buildContainerRoomAssignedUpdate(id, roomId, role || 'member', updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + } catch { + // Duplicate assignment, skip + } + } + + return reply.send({ assigned }); + }); + + // Remove container from room + app.delete('/v1/containers/:id/rooms/:roomId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string(), + roomId: z.string(), + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id, roomId } = request.params; + + const container = await db.containerLaunch.findFirst({ + where: { id, accountId: userId } + }); + if (!container) { + return reply.code(404).send({ error: 'Container not found' }); + } + + const assignment = await db.roomContainer.findUnique({ + where: { roomId_containerId: { roomId, containerId: id } } + }); + if (!assignment) { + return reply.code(404).send({ error: 'Container not assigned to this room' }); + } + + await db.roomContainer.delete({ + where: { id: assignment.id } + }); + + // Emit room removal event + const updSeq = await allocateUserSeq(userId); + const payload = buildContainerRoomRemovedUpdate(id, roomId, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ userId, payload, recipientFilter: { type: 'user-scoped-only' } }); + + return reply.send({ success: true }); + }); + + // List containers in a room (with status) + app.get('/v1/rooms/:id/containers', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + // Verify room belongs to user + const room = await db.room.findFirst({ + where: { id, accountId: userId } + }); + if (!room) { + return reply.code(404).send({ error: 'Room not found' }); + } + + const roomContainers = await db.roomContainer.findMany({ + where: { roomId: id }, + include: { + container: true + }, + orderBy: { assignedAt: 'asc' } + }); + + return reply.send({ + containers: roomContainers.map(rc => ({ + id: rc.container.id, + name: rc.container.name, + containerType: rc.container.containerType, + agentType: rc.container.agentType, + projectRole: rc.container.projectRole, + agentVersion: rc.container.agentVersion, + status: rc.container.status, + statusMessage: rc.container.statusMessage, + machineId: rc.container.machineId, + image: rc.container.image, + tag: rc.container.tag, + lastSeenAt: rc.container.lastSeenAt?.getTime() || null, + assignedAt: rc.assignedAt.getTime(), + role: rc.role, + })) + }); + }); + + // List all containers for a project + app.get('/v1/projects/:id/containers', { + preHandler: app.authenticate, + schema: { + params: z.object({ id: z.string() }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + // Verify project belongs to user + const project = await db.project.findFirst({ + where: { id, accountId: userId } + }); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const containers = await db.containerLaunch.findMany({ + where: { projectId: id, accountId: userId }, + orderBy: { createdAt: 'desc' }, + include: { + roomContainers: { + select: { roomId: true, role: true, assignedAt: true } + } + } + }); + + return reply.send({ + containers: containers.map(c => ({ + id: c.id, + name: c.name, + containerName: c.containerName, + image: c.image, + tag: c.tag, + containerType: c.containerType, + agentType: c.agentType, + projectRole: c.projectRole, + agentVersion: c.agentVersion, + status: c.status, + statusMessage: c.statusMessage, + machineId: c.machineId, + wapContainerId: c.wapContainerId, + lastSeenAt: c.lastSeenAt?.getTime() || null, + startedAt: c.startedAt?.getTime() || null, + stoppedAt: c.stoppedAt?.getTime() || null, + createdAt: c.createdAt.getTime(), + updatedAt: c.updatedAt.getTime(), + rooms: c.roomContainers.map(rc => ({ + roomId: rc.roomId, + role: rc.role, + assignedAt: rc.assignedAt.getTime(), + })), + })) + }); + }); +} diff --git a/sources/app/api/routes/machineTokenRoutes.ts b/sources/app/api/routes/machineTokenRoutes.ts new file mode 100644 index 00000000..f9ea0eef --- /dev/null +++ b/sources/app/api/routes/machineTokenRoutes.ts @@ -0,0 +1,289 @@ +import { Fastify } from "../types"; +import { z } from "zod"; +import { db } from "@/storage/db"; +import { log } from "@/utils/log"; +import { auth } from "@/app/auth/auth"; +import { randomBytes } from "crypto"; + +// Generate a machine token with mkt_ prefix +function generateMachineToken(): string { + const bytes = randomBytes(32); + return `mkt_${bytes.toString('base64url')}`; +} + +export function machineTokenRoutes(app: Fastify) { + // Create a new machine token + app.post('/v1/machine-tokens', { + preHandler: app.authenticate, + schema: { + body: z.object({ + name: z.string().min(1).max(100), + expiresAt: z.string().datetime().optional() // ISO date string + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { name, expiresAt } = request.body; + + log({ module: 'machine-tokens', userId }, `Creating machine token: ${name}`); + + // Generate the actual bearer token using the auth system + const tokenId = generateMachineToken(); + const bearerToken = await auth.createToken(userId, { + type: 'machine', + tokenId, + name + }); + + // Store token metadata in database + const machineToken = await db.machineToken.create({ + data: { + accountId: userId, + name, + token: bearerToken, + expiresAt: expiresAt ? new Date(expiresAt) : null, + } + }); + + log({ module: 'machine-tokens', userId, tokenId: machineToken.id }, 'Machine token created'); + + // Return the full token ONCE - this is the only time it's visible + return reply.send({ + token: { + id: machineToken.id, + name: machineToken.name, + // Return the full bearer token only on creation + value: bearerToken, + expiresAt: machineToken.expiresAt?.toISOString() || null, + createdAt: machineToken.createdAt.toISOString(), + } + }); + }); + + // List all machine tokens for the user + app.get('/v1/machine-tokens', { + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + + const tokens = await db.machineToken.findMany({ + where: { accountId: userId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + // Never return the actual token value in list + } + }); + + return reply.send({ + tokens: tokens.map(t => ({ + id: t.id, + name: t.name, + lastUsedAt: t.lastUsedAt?.toISOString() || null, + expiresAt: t.expiresAt?.toISOString() || null, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + // Show only prefix of token for identification + tokenPrefix: 'mkt_...' + })) + }); + }); + + // Get a single machine token + app.get('/v1/machine-tokens/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const token = await db.machineToken.findFirst({ + where: { + id, + accountId: userId + }, + select: { + id: true, + name: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + } + }); + + if (!token) { + return reply.code(404).send({ error: 'Token not found' }); + } + + return reply.send({ + token: { + id: token.id, + name: token.name, + lastUsedAt: token.lastUsedAt?.toISOString() || null, + expiresAt: token.expiresAt?.toISOString() || null, + createdAt: token.createdAt.toISOString(), + updatedAt: token.updatedAt.toISOString(), + tokenPrefix: 'mkt_...' + } + }); + }); + + // Update token name + app.patch('/v1/machine-tokens/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + name: z.string().min(1).max(100) + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { name } = request.body; + + const existing = await db.machineToken.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!existing) { + return reply.code(404).send({ error: 'Token not found' }); + } + + const updated = await db.machineToken.update({ + where: { id }, + data: { name }, + select: { + id: true, + name: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + } + }); + + log({ module: 'machine-tokens', userId, tokenId: id }, `Token renamed to: ${name}`); + + return reply.send({ + token: { + id: updated.id, + name: updated.name, + lastUsedAt: updated.lastUsedAt?.toISOString() || null, + expiresAt: updated.expiresAt?.toISOString() || null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + tokenPrefix: 'mkt_...' + } + }); + }); + + // Delete/revoke a machine token + app.delete('/v1/machine-tokens/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const existing = await db.machineToken.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!existing) { + return reply.code(404).send({ error: 'Token not found' }); + } + + // Invalidate the token in auth cache + auth.invalidateToken(existing.token); + + // Delete from database + await db.machineToken.delete({ + where: { id } + }); + + log({ module: 'machine-tokens', userId, tokenId: id }, 'Token revoked'); + + return reply.send({ success: true }); + }); + + // Rotate/regenerate a machine token + app.post('/v1/machine-tokens/:id/rotate', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const existing = await db.machineToken.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!existing) { + return reply.code(404).send({ error: 'Token not found' }); + } + + // Invalidate old token + auth.invalidateToken(existing.token); + + // Generate new token + const tokenId = generateMachineToken(); + const newBearerToken = await auth.createToken(userId, { + type: 'machine', + tokenId, + name: existing.name + }); + + // Update in database + const updated = await db.machineToken.update({ + where: { id }, + data: { + token: newBearerToken, + updatedAt: new Date() + } + }); + + log({ module: 'machine-tokens', userId, tokenId: id }, 'Token rotated'); + + // Return the new token value + return reply.send({ + token: { + id: updated.id, + name: updated.name, + value: newBearerToken, // New token value + expiresAt: updated.expiresAt?.toISOString() || null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + } + }); + }); +} diff --git a/sources/app/api/routes/projectNoteRoutes.ts b/sources/app/api/routes/projectNoteRoutes.ts new file mode 100644 index 00000000..53236913 --- /dev/null +++ b/sources/app/api/routes/projectNoteRoutes.ts @@ -0,0 +1,253 @@ +import { eventRouter } from "@/app/events/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { log } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import type { UpdatePayload } from "@/app/events/eventRouter"; + +// === EVENT BUILDERS === + +function buildNewProjectNoteUpdate(note: { + id: string; + projectId: string; + sourceAgentType: string; + targetAgentType: string; + title: string; + status: string; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-project-note', + noteId: note.id, + projectId: note.projectId, + sourceAgentType: note.sourceAgentType, + targetAgentType: note.targetAgentType, + title: note.title, + status: note.status, + createdAt: note.createdAt.getTime(), + }, + createdAt: Date.now() + }; +} + +// === ROUTES === + +export function projectNoteRoutes(app: Fastify) { + + // List notes for a project (filterable) + app.get('/v1/projects/:projectId/notes', { + schema: { + params: z.object({ projectId: z.string() }), + querystring: z.object({ + targetAgentType: z.string().optional(), + status: z.string().optional(), + }).optional() + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { projectId } = request.params; + const query = request.query as { targetAgentType?: string; status?: string } | undefined; + + // Verify project belongs to user + const project = await db.project.findFirst({ + where: { id: projectId, accountId: userId } + }); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const where: any = { projectId }; + if (query?.targetAgentType) { + where.targetAgentType = query.targetAgentType; + } + if (query?.status) { + where.status = query.status; + } + + const notes = await db.projectNote.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + return reply.send({ + notes: notes.map(n => ({ + id: n.id, + projectId: n.projectId, + accountId: n.accountId, + sourceAgentType: n.sourceAgentType, + targetAgentType: n.targetAgentType, + title: n.title, + content: n.content, + status: n.status, + metadata: n.metadata, + createdAt: n.createdAt.getTime(), + updatedAt: n.updatedAt.getTime(), + })) + }); + }); + + // Create a project note + app.post('/v1/projects/:projectId/notes', { + schema: { + params: z.object({ projectId: z.string() }), + body: z.object({ + targetAgentType: z.string().min(1), + title: z.string().min(1), + content: z.string().min(1), + metadata: z.record(z.unknown()).optional(), + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { projectId } = request.params; + const { targetAgentType, title, content, metadata } = request.body; + + // Verify project belongs to user + const project = await db.project.findFirst({ + where: { id: projectId, accountId: userId } + }); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Determine sourceAgentType: from container's agentType (machine auth) or "user" + let sourceAgentType = 'user'; + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer mkt_')) { + const machineToken = await db.machineToken.findFirst({ + where: { accountId: userId }, + include: { + ContainerLaunch: { + where: { accountId: userId, projectId }, + select: { agentType: true }, + take: 1, + } + } + }); + if (machineToken?.ContainerLaunch[0]?.agentType) { + sourceAgentType = machineToken.ContainerLaunch[0].agentType; + } + } + + const note = await db.projectNote.create({ + data: { + projectId, + accountId: userId, + sourceAgentType, + targetAgentType, + title, + content, + metadata: metadata || undefined, + } + }); + + log({ module: 'project-note', projectId, userId }, `New note: ${note.id} (${sourceAgentType} → ${targetAgentType})`); + + // Emit event + const updSeq = await allocateUserSeq(userId); + const payload = buildNewProjectNoteUpdate(note, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + note: { + id: note.id, + projectId: note.projectId, + accountId: note.accountId, + sourceAgentType: note.sourceAgentType, + targetAgentType: note.targetAgentType, + title: note.title, + content: note.content, + status: note.status, + metadata: note.metadata, + createdAt: note.createdAt.getTime(), + updatedAt: note.updatedAt.getTime(), + } + }); + }); + + // Update note status + app.patch('/v1/notes/:id', { + schema: { + params: z.object({ id: z.string() }), + body: z.object({ + status: z.enum(['pending', 'acknowledged', 'completed']).optional(), + content: z.string().optional(), + title: z.string().optional(), + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { status, content, title } = request.body; + + const existing = await db.projectNote.findFirst({ + where: { id, project: { accountId: userId } } + }); + if (!existing) { + return reply.code(404).send({ error: 'Note not found' }); + } + + const updated = await db.projectNote.update({ + where: { id }, + data: { + status: status ?? undefined, + content: content ?? undefined, + title: title ?? undefined, + } + }); + + log({ module: 'project-note', noteId: id, userId }, `Note updated`); + + return reply.send({ + note: { + id: updated.id, + projectId: updated.projectId, + accountId: updated.accountId, + sourceAgentType: updated.sourceAgentType, + targetAgentType: updated.targetAgentType, + title: updated.title, + content: updated.content, + status: updated.status, + metadata: updated.metadata, + createdAt: updated.createdAt.getTime(), + updatedAt: updated.updatedAt.getTime(), + } + }); + }); + + // Delete note + app.delete('/v1/notes/:id', { + schema: { + params: z.object({ id: z.string() }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const existing = await db.projectNote.findFirst({ + where: { id, accountId: userId } + }); + if (!existing) { + return reply.code(404).send({ error: 'Note not found' }); + } + + await db.projectNote.delete({ where: { id } }); + + log({ module: 'project-note', noteId: id, userId }, `Note deleted`); + + return reply.send({ success: true }); + }); +} diff --git a/sources/app/api/routes/projectRoutes.ts b/sources/app/api/routes/projectRoutes.ts new file mode 100644 index 00000000..e2dc0152 --- /dev/null +++ b/sources/app/api/routes/projectRoutes.ts @@ -0,0 +1,619 @@ +import { eventRouter } from "@/app/events/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { log } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import { buildNewProjectUpdate, buildUpdateProjectUpdate, buildDeleteProjectUpdate, buildMoveProjectUpdate } from "@/app/events/projectEvents"; + +export function projectRoutes(app: Fastify) { + + // List all projects (flat with path info) + app.get('/v1/projects', { + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + + const projects = await db.project.findMany({ + where: { accountId: userId }, + orderBy: [{ path: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }], + select: { + id: true, + parentId: true, + path: true, + depth: true, + name: true, + description: true, + metadata: true, + sortOrder: true, + createdAt: true, + updatedAt: true, + } + }); + + return reply.send({ + projects: projects.map((p) => ({ + id: p.id, + accountId: userId, + parentId: p.parentId, + path: p.path, + depth: p.depth, + name: p.name, + description: p.description, + metadata: p.metadata, + sortOrder: p.sortOrder, + createdAt: p.createdAt.getTime(), + updatedAt: p.updatedAt.getTime(), + })) + }); + }); + + // Get full project tree structure + app.get('/v1/projects/tree', { + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + + const projects = await db.project.findMany({ + where: { accountId: userId }, + orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }], + select: { + id: true, + parentId: true, + path: true, + depth: true, + name: true, + description: true, + metadata: true, + sortOrder: true, + createdAt: true, + updatedAt: true, + } + }); + + // Build tree structure + const projectMap = new Map(); + const roots: any[] = []; + + // First pass: create all nodes + for (const p of projects) { + projectMap.set(p.id, { + id: p.id, + accountId: userId, + parentId: p.parentId, + path: p.path, + depth: p.depth, + name: p.name, + description: p.description, + metadata: p.metadata, + sortOrder: p.sortOrder, + createdAt: p.createdAt.getTime(), + updatedAt: p.updatedAt.getTime(), + children: [], + }); + } + + // Second pass: build hierarchy + for (const p of projects) { + const node = projectMap.get(p.id); + if (p.parentId && projectMap.has(p.parentId)) { + projectMap.get(p.parentId).children.push(node); + } else if (!p.parentId) { + roots.push(node); + } + } + + return reply.send({ tree: roots }); + }); + + // Get single project with children + app.get('/v1/projects/:id', { + schema: { + params: z.object({ + id: z.string() + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const project = await db.project.findFirst({ + where: { id, accountId: userId }, + include: { + children: { + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + select: { + id: true, + parentId: true, + path: true, + depth: true, + name: true, + description: true, + metadata: true, + sortOrder: true, + createdAt: true, + updatedAt: true, + } + }, + rooms: { + orderBy: { name: 'asc' }, + select: { + id: true, + projectId: true, + name: true, + description: true, + metadata: true, + status: true, + createdAt: true, + updatedAt: true, + } + } + } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + return reply.send({ + project: { + id: project.id, + accountId: userId, + parentId: project.parentId, + path: project.path, + depth: project.depth, + name: project.name, + description: project.description, + metadata: project.metadata, + sortOrder: project.sortOrder, + createdAt: project.createdAt.getTime(), + updatedAt: project.updatedAt.getTime(), + children: project.children.map(c => ({ + id: c.id, + accountId: userId, + parentId: c.parentId, + path: c.path, + depth: c.depth, + name: c.name, + description: c.description, + metadata: c.metadata, + sortOrder: c.sortOrder, + createdAt: c.createdAt.getTime(), + updatedAt: c.updatedAt.getTime(), + })), + rooms: project.rooms.map(r => ({ + id: r.id, + accountId: userId, + projectId: r.projectId, + name: r.name, + description: r.description, + metadata: r.metadata, + status: r.status, + createdAt: r.createdAt.getTime(), + updatedAt: r.updatedAt.getTime(), + })) + } + }); + }); + + // Get project ancestors (for breadcrumbs) + app.get('/v1/projects/:id/ancestors', { + schema: { + params: z.object({ + id: z.string() + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const project = await db.project.findFirst({ + where: { id, accountId: userId }, + select: { path: true } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Parse path and fetch ancestors + const ancestorIds = project.path.split('/').filter(Boolean); + + const ancestors = await db.project.findMany({ + where: { + id: { in: ancestorIds }, + accountId: userId + }, + select: { + id: true, + name: true, + depth: true, + }, + orderBy: { depth: 'asc' } + }); + + return reply.send({ + ancestors: ancestors.map(a => ({ + id: a.id, + name: a.name, + depth: a.depth, + })) + }); + }); + + // Create project + app.post('/v1/projects', { + schema: { + body: z.object({ + name: z.string().min(1).max(255), + description: z.string().max(1000).optional(), + parentId: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { name, description, parentId, metadata } = request.body; + + // If parentId is provided, verify it exists and belongs to user + let parentPath = '/'; + let depth = 0; + + if (parentId) { + const parent = await db.project.findFirst({ + where: { id: parentId, accountId: userId }, + select: { path: true, depth: true } + }); + + if (!parent) { + return reply.code(404).send({ error: 'Parent project not found' }); + } + + parentPath = parent.path; + depth = parent.depth + 1; + } + + // Check for duplicate name under same parent + const existing = await db.project.findFirst({ + where: { + accountId: userId, + parentId: parentId || null, + name: name + } + }); + + if (existing) { + return reply.code(409).send({ error: 'A project with this name already exists in this location' }); + } + + // Create project + const project = await db.project.create({ + data: { + accountId: userId, + parentId: parentId || null, + name, + description, + metadata, + depth, + path: '/', // Will be updated after creation + } + }); + + // Update path to include this project's ID + const newPath = parentPath === '/' ? `/${project.id}/` : `${parentPath}${project.id}/`; + await db.project.update({ + where: { id: project.id }, + data: { path: newPath } + }); + + const updatedProject = await db.project.findUnique({ + where: { id: project.id } + }); + + log({ module: 'project-create', projectId: project.id, userId }, `Created project: ${project.id}`); + + // Emit new project event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildNewProjectUpdate({ + id: updatedProject!.id, + accountId: userId, + parentId: updatedProject!.parentId, + path: updatedProject!.path, + depth: updatedProject!.depth, + name: updatedProject!.name, + description: updatedProject!.description, + metadata: updatedProject!.metadata, + sortOrder: updatedProject!.sortOrder, + createdAt: updatedProject!.createdAt, + updatedAt: updatedProject!.updatedAt, + }, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ + project: { + id: updatedProject!.id, + accountId: userId, + parentId: updatedProject!.parentId, + path: updatedProject!.path, + depth: updatedProject!.depth, + name: updatedProject!.name, + description: updatedProject!.description, + metadata: updatedProject!.metadata, + sortOrder: updatedProject!.sortOrder, + createdAt: updatedProject!.createdAt.getTime(), + updatedAt: updatedProject!.updatedAt.getTime(), + } + }); + }); + + // Update project + app.patch('/v1/projects/:id', { + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).nullable().optional(), + metadata: z.record(z.unknown()).optional(), + sortOrder: z.number().int().optional(), + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { name, description, metadata, sortOrder } = request.body; + + // Verify project exists and belongs to user + const existing = await db.project.findFirst({ + where: { id, accountId: userId } + }); + + if (!existing) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Check for duplicate name if name is being changed + if (name && name !== existing.name) { + const duplicate = await db.project.findFirst({ + where: { + accountId: userId, + parentId: existing.parentId, + name: name, + id: { not: id } + } + }); + + if (duplicate) { + return reply.code(409).send({ error: 'A project with this name already exists in this location' }); + } + } + + const project = await db.project.update({ + where: { id }, + data: { + name: name ?? undefined, + description: description !== undefined ? description : undefined, + metadata: metadata ?? undefined, + sortOrder: sortOrder ?? undefined, + } + }); + + log({ module: 'project-update', projectId: id, userId }, `Updated project: ${id}`); + + // Emit update event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateProjectUpdate(id, updSeq, randomKeyNaked(12), { + name: name, + description: description !== undefined ? description : undefined, + metadata: metadata, + sortOrder: sortOrder, + }); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ + project: { + id: project.id, + accountId: userId, + parentId: project.parentId, + path: project.path, + depth: project.depth, + name: project.name, + description: project.description, + metadata: project.metadata, + sortOrder: project.sortOrder, + createdAt: project.createdAt.getTime(), + updatedAt: project.updatedAt.getTime(), + } + }); + }); + + // Move project to new parent + app.patch('/v1/projects/:id/move', { + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + parentId: z.string().nullable() + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { parentId } = request.body; + + // Verify project exists and belongs to user + const project = await db.project.findFirst({ + where: { id, accountId: userId } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Cannot move to itself + if (parentId === id) { + return reply.code(400).send({ error: 'Cannot move project to itself' }); + } + + // Verify new parent exists and belongs to user (if specified) + let newParentPath = '/'; + let newDepth = 0; + + if (parentId) { + const newParent = await db.project.findFirst({ + where: { id: parentId, accountId: userId } + }); + + if (!newParent) { + return reply.code(404).send({ error: 'Target parent project not found' }); + } + + // Prevent moving to a descendant + if (newParent.path.includes(`/${id}/`)) { + return reply.code(400).send({ error: 'Cannot move project to one of its descendants' }); + } + + newParentPath = newParent.path; + newDepth = newParent.depth + 1; + } + + // Check for duplicate name in new location + const duplicate = await db.project.findFirst({ + where: { + accountId: userId, + parentId: parentId, + name: project.name, + id: { not: id } + } + }); + + if (duplicate) { + return reply.code(409).send({ error: 'A project with this name already exists in the target location' }); + } + + const oldPath = project.path; + const newPath = newParentPath === '/' ? `/${id}/` : `${newParentPath}${id}/`; + const depthDiff = newDepth - project.depth; + + // Update this project + await db.project.update({ + where: { id }, + data: { + parentId, + path: newPath, + depth: newDepth, + } + }); + + // Update all descendants - update their paths and depths + const descendants = await db.project.findMany({ + where: { + accountId: userId, + path: { startsWith: oldPath }, + id: { not: id } + } + }); + + for (const desc of descendants) { + const updatedDescPath = desc.path.replace(oldPath, newPath); + await db.project.update({ + where: { id: desc.id }, + data: { + path: updatedDescPath, + depth: desc.depth + depthDiff, + } + }); + } + + const updatedProject = await db.project.findUnique({ + where: { id } + }); + + log({ module: 'project-move', projectId: id, userId, from: oldPath, to: newPath }, `Moved project: ${id}`); + + // Emit move event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildMoveProjectUpdate(id, parentId, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ + project: { + id: updatedProject!.id, + accountId: userId, + parentId: updatedProject!.parentId, + path: updatedProject!.path, + depth: updatedProject!.depth, + name: updatedProject!.name, + description: updatedProject!.description, + metadata: updatedProject!.metadata, + sortOrder: updatedProject!.sortOrder, + createdAt: updatedProject!.createdAt.getTime(), + updatedAt: updatedProject!.updatedAt.getTime(), + } + }); + }); + + // Delete project (cascades to children and rooms) + app.delete('/v1/projects/:id', { + schema: { + params: z.object({ + id: z.string() + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + // Verify project exists and belongs to user + const project = await db.project.findFirst({ + where: { id, accountId: userId } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Delete project (cascade will handle children and rooms) + await db.project.delete({ + where: { id } + }); + + log({ module: 'project-delete', projectId: id, userId }, `Deleted project: ${id}`); + + // Emit delete event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildDeleteProjectUpdate(id, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ success: true }); + }); +} diff --git a/sources/app/api/routes/roomMessageRoutes.ts b/sources/app/api/routes/roomMessageRoutes.ts new file mode 100644 index 00000000..1c6dc60a --- /dev/null +++ b/sources/app/api/routes/roomMessageRoutes.ts @@ -0,0 +1,249 @@ +import { eventRouter } from "@/app/events/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { log } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import type { UpdatePayload } from "@/app/events/eventRouter"; + +// === EVENT BUILDERS === + +function buildNewRoomMessageUpdate(message: { + id: string; + roomId: string; + accountId: string; + content: string; + type: string; + metadata: unknown; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-room-message', + messageId: message.id, + roomId: message.roomId, + accountId: message.accountId, + content: message.content, + messageType: message.type, + metadata: message.metadata, + createdAt: message.createdAt.getTime(), + }, + createdAt: Date.now() + }; +} + +// === ROUTES === + +export function roomMessageRoutes(app: Fastify) { + + // List messages in a room (cursor-paginated, newest first) + app.get('/v1/rooms/:roomId/messages', { + schema: { + params: z.object({ roomId: z.string() }), + querystring: z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(50), + }).optional() + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { roomId } = request.params; + const query = request.query as { cursor?: string; limit?: number } | undefined; + const limit = query?.limit ?? 50; + + // Verify room belongs to user's account + const room = await db.room.findFirst({ + where: { id: roomId, accountId: userId } + }); + if (!room) { + return reply.code(404).send({ error: 'Room not found' }); + } + + const where: any = { roomId }; + if (query?.cursor) { + where.createdAt = { lt: new Date(query.cursor) }; + } + + const messages = await db.roomMessage.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit + 1, // Fetch one extra to determine if there's a next page + }); + + const hasMore = messages.length > limit; + const page = hasMore ? messages.slice(0, limit) : messages; + const nextCursor = hasMore ? page[page.length - 1].createdAt.toISOString() : null; + + return reply.send({ + messages: page.map(m => ({ + id: m.id, + roomId: m.roomId, + accountId: m.accountId, + content: m.content, + type: m.type, + metadata: m.metadata, + createdAt: m.createdAt.getTime(), + updatedAt: m.updatedAt.getTime(), + })), + nextCursor, + }); + }); + + // Send message to room + app.post('/v1/rooms/:roomId/messages', { + schema: { + params: z.object({ roomId: z.string() }), + body: z.object({ + content: z.string().min(1), + type: z.enum(['user', 'assistant', 'system']).default('user'), + metadata: z.record(z.unknown()).optional(), + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { roomId } = request.params; + const { content, type, metadata } = request.body; + + // Verify room belongs to user's account + const room = await db.room.findFirst({ + where: { id: roomId, accountId: userId } + }); + if (!room) { + return reply.code(404).send({ error: 'Room not found' }); + } + + // For machine auth (containers), check RoomContainer assignment and force type to 'assistant' + let messageType = type; + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer mkt_')) { + // Machine token — verify container is assigned to room + const machineToken = await db.machineToken.findFirst({ + where: { accountId: userId }, + include: { + ContainerLaunch: { + where: { accountId: userId }, + include: { + roomContainers: { where: { roomId } } + }, + take: 1 + } + } + }); + + // If token found but no container assigned to room, deny + if (machineToken && machineToken.ContainerLaunch.length > 0) { + const container = machineToken.ContainerLaunch[0]; + if (container.roomContainers.length === 0) { + return reply.code(403).send({ error: 'Container not assigned to this room' }); + } + } + + messageType = 'assistant'; + } + + const message = await db.roomMessage.create({ + data: { + roomId, + accountId: userId, + content, + type: messageType, + metadata: metadata || undefined, + } + }); + + log({ module: 'room-message', roomId, userId }, `New message: ${message.id}`); + + // Emit update event to all user connections (including machine-scoped) + const updSeq = await allocateUserSeq(userId); + const payload = buildNewRoomMessageUpdate(message, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + message: { + id: message.id, + roomId: message.roomId, + accountId: message.accountId, + content: message.content, + type: message.type, + metadata: message.metadata, + createdAt: message.createdAt.getTime(), + updatedAt: message.updatedAt.getTime(), + } + }); + }); + + // Edit message content + app.patch('/v1/messages/:id', { + schema: { + params: z.object({ id: z.string() }), + body: z.object({ + content: z.string().min(1), + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { content } = request.body; + + const existing = await db.roomMessage.findFirst({ + where: { id, accountId: userId } + }); + if (!existing) { + return reply.code(404).send({ error: 'Message not found' }); + } + + const updated = await db.roomMessage.update({ + where: { id }, + data: { content } + }); + + log({ module: 'room-message', messageId: id, userId }, `Message edited`); + + return reply.send({ + message: { + id: updated.id, + roomId: updated.roomId, + accountId: updated.accountId, + content: updated.content, + type: updated.type, + metadata: updated.metadata, + createdAt: updated.createdAt.getTime(), + updatedAt: updated.updatedAt.getTime(), + } + }); + }); + + // Delete message + app.delete('/v1/messages/:id', { + schema: { + params: z.object({ id: z.string() }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const existing = await db.roomMessage.findFirst({ + where: { id, accountId: userId } + }); + if (!existing) { + return reply.code(404).send({ error: 'Message not found' }); + } + + await db.roomMessage.delete({ where: { id } }); + + log({ module: 'room-message', messageId: id, userId }, `Message deleted`); + + return reply.send({ success: true }); + }); +} diff --git a/sources/app/api/routes/roomRoutes.ts b/sources/app/api/routes/roomRoutes.ts new file mode 100644 index 00000000..854029fb --- /dev/null +++ b/sources/app/api/routes/roomRoutes.ts @@ -0,0 +1,343 @@ +import { eventRouter } from "@/app/events/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { log } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import { buildNewRoomUpdate, buildUpdateRoomUpdate, buildDeleteRoomUpdate } from "@/app/events/projectEvents"; + +const RoomStatusEnum = z.enum(['active', 'archived', 'locked']); + +export function roomRoutes(app: Fastify) { + + // List rooms in a project + app.get('/v1/projects/:projectId/rooms', { + schema: { + params: z.object({ + projectId: z.string() + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { projectId } = request.params; + + // Verify project exists and belongs to user + const project = await db.project.findFirst({ + where: { id: projectId, accountId: userId } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const rooms = await db.room.findMany({ + where: { projectId }, + orderBy: { name: 'asc' }, + select: { + id: true, + projectId: true, + name: true, + description: true, + metadata: true, + status: true, + createdAt: true, + updatedAt: true, + } + }); + + return reply.send({ + rooms: rooms.map((r) => ({ + id: r.id, + accountId: userId, + projectId: r.projectId, + name: r.name, + description: r.description, + metadata: r.metadata, + status: r.status, + createdAt: r.createdAt.getTime(), + updatedAt: r.updatedAt.getTime(), + })) + }); + }); + + // Get room details + app.get('/v1/rooms/:id', { + schema: { + params: z.object({ + id: z.string() + }) + }, + preHandler: app.authenticate, + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + const room = await db.room.findFirst({ + where: { id, accountId: userId }, + include: { + project: { + select: { + id: true, + name: true, + path: true, + } + }, + roomSessions: { + include: { + session: { + select: { + id: true, + metadata: true, + active: true, + lastActiveAt: true, + } + } + } + } + } + }); + + if (!room) { + return reply.code(404).send({ error: 'Room not found' }); + } + + return reply.send({ + room: { + id: room.id, + accountId: userId, + projectId: room.projectId, + name: room.name, + description: room.description, + metadata: room.metadata, + status: room.status, + createdAt: room.createdAt.getTime(), + updatedAt: room.updatedAt.getTime(), + project: { + id: room.project.id, + name: room.project.name, + path: room.project.path, + }, + sessions: room.roomSessions.map(rs => ({ + id: rs.session.id, + role: rs.role, + joinedAt: rs.joinedAt.getTime(), + metadata: rs.session.metadata, + active: rs.session.active, + lastActiveAt: rs.session.lastActiveAt.getTime(), + })) + } + }); + }); + + // Create room + app.post('/v1/projects/:projectId/rooms', { + schema: { + params: z.object({ + projectId: z.string() + }), + body: z.object({ + name: z.string().min(1).max(255), + description: z.string().max(1000).optional(), + metadata: z.record(z.unknown()).optional(), + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { projectId } = request.params; + const { name, description, metadata } = request.body; + + // Verify project exists and belongs to user + const project = await db.project.findFirst({ + where: { id: projectId, accountId: userId } + }); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + // Check for duplicate name in project + const existing = await db.room.findFirst({ + where: { projectId, name } + }); + + if (existing) { + return reply.code(409).send({ error: 'A room with this name already exists in this project' }); + } + + const room = await db.room.create({ + data: { + accountId: userId, + projectId, + name, + description, + metadata, + } + }); + + log({ module: 'room-create', roomId: room.id, projectId, userId }, `Created room: ${room.id}`); + + // Emit new room event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildNewRoomUpdate({ + id: room.id, + accountId: userId, + projectId: room.projectId, + name: room.name, + description: room.description, + metadata: room.metadata, + status: room.status, + createdAt: room.createdAt, + updatedAt: room.updatedAt, + }, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ + room: { + id: room.id, + accountId: userId, + projectId: room.projectId, + name: room.name, + description: room.description, + metadata: room.metadata, + status: room.status, + createdAt: room.createdAt.getTime(), + updatedAt: room.updatedAt.getTime(), + } + }); + }); + + // Update room + app.patch('/v1/rooms/:id', { + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).nullable().optional(), + metadata: z.record(z.unknown()).optional(), + status: RoomStatusEnum.optional(), + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { name, description, metadata, status } = request.body; + + // Verify room exists and belongs to user + const existing = await db.room.findFirst({ + where: { id, accountId: userId } + }); + + if (!existing) { + return reply.code(404).send({ error: 'Room not found' }); + } + + // Check for duplicate name if name is being changed + if (name && name !== existing.name) { + const duplicate = await db.room.findFirst({ + where: { + projectId: existing.projectId, + name: name, + id: { not: id } + } + }); + + if (duplicate) { + return reply.code(409).send({ error: 'A room with this name already exists in this project' }); + } + } + + const room = await db.room.update({ + where: { id }, + data: { + name: name ?? undefined, + description: description !== undefined ? description : undefined, + metadata: metadata ?? undefined, + status: status ?? undefined, + } + }); + + log({ module: 'room-update', roomId: id, userId }, `Updated room: ${id}`); + + // Emit update event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateRoomUpdate(id, existing.projectId, updSeq, randomKeyNaked(12), { + name, + description: description !== undefined ? description : undefined, + metadata, + status, + }); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ + room: { + id: room.id, + accountId: userId, + projectId: room.projectId, + name: room.name, + description: room.description, + metadata: room.metadata, + status: room.status, + createdAt: room.createdAt.getTime(), + updatedAt: room.updatedAt.getTime(), + } + }); + }); + + // Delete room + app.delete('/v1/rooms/:id', { + schema: { + params: z.object({ + id: z.string() + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + // Verify room exists and belongs to user + const room = await db.room.findFirst({ + where: { id, accountId: userId }, + select: { id: true, projectId: true } + }); + + if (!room) { + return reply.code(404).send({ error: 'Room not found' }); + } + + // Delete room + await db.room.delete({ + where: { id } + }); + + log({ module: 'room-delete', roomId: id, userId }, `Deleted room: ${id}`); + + // Emit delete event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildDeleteRoomUpdate(id, room.projectId, updSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'user-scoped-only' } + }); + + return reply.send({ success: true }); + }); +} diff --git a/sources/app/api/utils/enableAuthentication.ts b/sources/app/api/utils/enableAuthentication.ts index 51e2fb9c..d12dee6f 100644 --- a/sources/app/api/utils/enableAuthentication.ts +++ b/sources/app/api/utils/enableAuthentication.ts @@ -1,10 +1,46 @@ import { Fastify } from "../types"; import { log } from "@/utils/log"; import { auth } from "@/app/auth/auth"; +import Session from "supertokens-node/recipe/session"; +import { db } from "@/storage/db"; export function enableAuthentication(app: Fastify) { app.decorate('authenticate', async function (request: any, reply: any) { try { + // First, try SuperTokens session authentication + try { + const session = await Session.getSession(request, reply, { + sessionRequired: false, + }); + + if (session) { + const supertokensUserId = session.getUserId(); + const accessTokenPayload = session.getAccessTokenPayload(); + + // Try to get accountId from payload first (faster) + let accountId = accessTokenPayload.accountId; + + if (!accountId) { + // Fall back to database lookup + const account = await db.account.findFirst({ + where: { supertokensUserId } + }); + accountId = account?.id; + } + + if (accountId) { + log({ module: 'auth-decorator' }, `SuperTokens auth success - user: ${accountId}`); + request.userId = accountId; + request.supertokensSession = session; + return; + } + } + } catch (stErr) { + // SuperTokens session not found or error, fall through to legacy auth + log({ module: 'auth-decorator' }, `SuperTokens check failed, trying legacy auth: ${stErr}`); + } + + // Fall back to legacy Bearer token authentication const authHeader = request.headers.authorization; log({ module: 'auth-decorator' }, `Auth check - path: ${request.url}, has header: ${!!authHeader}, header start: ${authHeader?.substring(0, 50)}...`); if (!authHeader || !authHeader.startsWith('Bearer ')) { @@ -19,7 +55,7 @@ export function enableAuthentication(app: Fastify) { return reply.code(401).send({ error: 'Invalid token' }); } - log({ module: 'auth-decorator' }, `Auth success - user: ${verified.userId}`); + log({ module: 'auth-decorator' }, `Legacy auth success - user: ${verified.userId}`); request.userId = verified.userId; } catch (error) { return reply.code(401).send({ error: 'Authentication failed' }); diff --git a/sources/app/auth/supertokens.ts b/sources/app/auth/supertokens.ts new file mode 100644 index 00000000..b226a42a --- /dev/null +++ b/sources/app/auth/supertokens.ts @@ -0,0 +1,177 @@ +import supertokens from "supertokens-node"; +import Session from "supertokens-node/recipe/session"; +import ThirdParty from "supertokens-node/recipe/thirdparty"; +import EmailPassword from "supertokens-node/recipe/emailpassword"; +import { db } from "@/storage/db"; +import { log } from "@/utils/log"; + +export function initSuperTokens() { + const connectionUri = process.env.SUPERTOKENS_CONNECTION_URI || "https://auth-api.dev.ai-armory.com"; + const apiDomain = process.env.API_DOMAIN || "https://happy-api.dev.ai-armory.com"; + const websiteDomain = process.env.WEBSITE_DOMAIN || "http://15.204.94.200:5175"; + + log({ module: 'supertokens' }, `Initializing SuperTokens with: + - connectionUri: ${connectionUri} + - apiDomain: ${apiDomain} + - websiteDomain: ${websiteDomain} + `); + + supertokens.init({ + framework: "fastify", + supertokens: { + connectionURI: connectionUri, + apiKey: process.env.SUPERTOKENS_API_KEY, + }, + appInfo: { + appName: "Happy Server", + apiDomain, + websiteDomain, + apiBasePath: "/auth", + websiteBasePath: "/auth", + }, + recipeList: [ + // Third-party (Google OAuth) + ThirdParty.init({ + signInAndUpFeature: { + providers: [ + { + config: { + thirdPartyId: "google", + clients: [{ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }], + }, + }, + ], + }, + override: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + signInUp: async function(input) { + const response = await originalImplementation.signInUp(input); + + if (response.status === "OK") { + const { id: supertokensUserId, thirdParty, emails } = response.user; + const email = emails[0] || null; + const authProvider = thirdParty?.[0]?.id || "thirdparty"; + + log({ module: 'supertokens' }, `Third party sign in/up: ${email}, provider: ${authProvider}`); + + // Find or create account + let account = await db.account.findFirst({ + where: { supertokensUserId } + }); + + if (!account && email) { + // Check if account exists with this email + account = await db.account.findFirst({ + where: { email } + }); + + if (account) { + // Link existing account + await db.account.update({ + where: { id: account.id }, + data: { supertokensUserId, authProvider } + }); + } + } + + if (!account) { + // Create new account - generate a placeholder publicKey + const crypto = await import("crypto"); + const placeholderPublicKey = crypto.randomBytes(32).toString('hex'); + + account = await db.account.create({ + data: { + publicKey: placeholderPublicKey, + supertokensUserId, + email, + authProvider, + } + }); + + log({ module: 'supertokens' }, `Created new account: ${account.id}`); + } + } + + return response; + }, + }; + }, + }, + }), + + // Email/Password + EmailPassword.init({ + override: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + signUp: async function(input) { + const response = await originalImplementation.signUp(input); + + if (response.status === "OK") { + const { id: supertokensUserId, emails } = response.user; + const email = emails[0] || null; + + log({ module: 'supertokens' }, `Email password sign up: ${email}`); + + // Create new account + const crypto = await import("crypto"); + const placeholderPublicKey = crypto.randomBytes(32).toString('hex'); + + await db.account.create({ + data: { + publicKey: placeholderPublicKey, + supertokensUserId, + email, + authProvider: "emailpassword", + } + }); + + log({ module: 'supertokens' }, `Created account for email: ${email}`); + } + + return response; + }, + }; + }, + }, + }), + + // Session management + Session.init({ + override: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + createNewSession: async function(input) { + // Get the account ID linked to this SuperTokens user + const account = await db.account.findFirst({ + where: { supertokensUserId: input.userId } + }); + + // Store account ID in session payload for easy access + if (account) { + input.accessTokenPayload = { + ...input.accessTokenPayload, + accountId: account.id, + }; + } + + return originalImplementation.createNewSession(input); + }, + }; + }, + }, + }), + ], + }); + + log({ module: 'supertokens' }, 'SuperTokens initialized successfully'); +} + +export { supertokens, Session, ThirdParty, EmailPassword }; diff --git a/sources/app/events/containerEvents.ts b/sources/app/events/containerEvents.ts new file mode 100644 index 00000000..5fe0b1ae --- /dev/null +++ b/sources/app/events/containerEvents.ts @@ -0,0 +1,134 @@ +import type { UpdatePayload, EphemeralPayload } from "./eventRouter"; + +// === CONTAINER EVENT BUILDERS === + +export function buildNewContainerLaunchUpdate(launch: { + id: string; + name: string; + image: string; + status: string; + containerType: string; + agentType: string | null; + projectRole: string | null; + agentVersion: string | null; + projectId: string | null; + wapContainerId: string | null; + machineId: string | null; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-container-launch', + launchId: launch.id, + name: launch.name, + image: launch.image, + status: launch.status, + containerType: launch.containerType, + agentType: launch.agentType, + projectRole: launch.projectRole, + agentVersion: launch.agentVersion, + projectId: launch.projectId, + wapContainerId: launch.wapContainerId, + machineId: launch.machineId, + createdAt: launch.createdAt.getTime(), + }, + createdAt: Date.now() + }; +} + +export function buildContainerStatusUpdate( + launchId: string, + status: string, + statusMessage: string | null, + wapContainerId: string | null, + machineId: string | null, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'container-status', + launchId, + status, + statusMessage, + wapContainerId, + machineId, + lastSeenAt: Date.now(), + }, + createdAt: Date.now() + }; +} + +export function buildDeleteContainerLaunchUpdate( + launchId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'delete-container-launch', + launchId, + }, + createdAt: Date.now() + }; +} + +export function buildContainerRoomAssignedUpdate( + launchId: string, + roomId: string, + role: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'container-room-assigned', + launchId, + roomId, + role, + }, + createdAt: Date.now() + }; +} + +export function buildContainerRoomRemovedUpdate( + launchId: string, + roomId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'container-room-removed', + launchId, + roomId, + }, + createdAt: Date.now() + }; +} + +// === EPHEMERAL EVENTS === + +export function buildContainerActivityEphemeral( + launchId: string, + machineId: string | null, + active: boolean +): EphemeralPayload { + return { + type: 'container-activity', + launchId, + machineId, + active, + timestamp: Date.now(), + }; +} diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index 6ba61fe8..61add99f 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -152,6 +152,108 @@ export type UpdateEvent = { value: string | null; // null indicates deletion version: number; // -1 for deleted keys }>; +} | { + type: 'new-project'; + id: string; + accountId: string; + parentId: string | null; + path: string; + depth: number; + name: string; + description: string | null; + metadata: unknown; + sortOrder: number; + createdAt: number; + updatedAt: number; +} | { + type: 'update-project'; + id: string; + name?: string; + description?: string | null; + metadata?: unknown; + sortOrder?: number; +} | { + type: 'move-project'; + id: string; + parentId: string | null; +} | { + type: 'delete-project'; + projectId: string; +} | { + type: 'new-room'; + id: string; + accountId: string; + projectId: string; + name: string; + description: string | null; + metadata: unknown; + status: string; + createdAt: number; + updatedAt: number; +} | { + type: 'update-room'; + id: string; + projectId: string; + name?: string; + description?: string | null; + metadata?: unknown; + status?: string; +} | { + type: 'delete-room'; + roomId: string; + projectId: string; +} | { + type: 'new-container-launch'; + launchId: string; + name: string; + image: string; + status: string; + containerType: string; + agentType: string | null; + projectRole: string | null; + agentVersion: string | null; + projectId: string | null; + wapContainerId: string | null; + machineId: string | null; + createdAt: number; +} | { + type: 'container-status'; + launchId: string; + status: string; + statusMessage: string | null; + wapContainerId: string | null; + machineId: string | null; + lastSeenAt: number | null; +} | { + type: 'delete-container-launch'; + launchId: string; +} | { + type: 'container-room-assigned'; + launchId: string; + roomId: string; + role: string; +} | { + type: 'container-room-removed'; + launchId: string; + roomId: string; +} | { + type: 'new-room-message'; + messageId: string; + roomId: string; + accountId: string; + content: string; + messageType: string; + metadata: unknown; + createdAt: number; +} | { + type: 'new-project-note'; + noteId: string; + projectId: string; + sourceAgentType: string; + targetAgentType: string; + title: string; + status: string; + createdAt: number; }; // === EPHEMERAL EVENT TYPES (Transient) === @@ -179,6 +281,12 @@ export type EphemeralEvent = { machineId: string; online: boolean; timestamp: number; +} | { + type: 'container-activity'; + launchId: string; + machineId: string | null; + active: boolean; + timestamp: number; }; // === EVENT PAYLOAD TYPES === diff --git a/sources/app/events/projectEvents.ts b/sources/app/events/projectEvents.ts new file mode 100644 index 00000000..5911c285 --- /dev/null +++ b/sources/app/events/projectEvents.ts @@ -0,0 +1,175 @@ +import { UpdatePayload } from "./eventRouter"; + +// === PROJECT EVENT TYPES === + +export interface ProjectData { + id: string; + accountId: string; + parentId: string | null; + path: string; + depth: number; + name: string; + description: string | null; + metadata: unknown; + sortOrder: number; + createdAt: Date; + updatedAt: Date; +} + +export interface RoomData { + id: string; + accountId: string; + projectId: string; + name: string; + description: string | null; + metadata: unknown; + status: string; + createdAt: Date; + updatedAt: Date; +} + +// === PROJECT EVENT BUILDERS === + +export function buildNewProjectUpdate(project: ProjectData, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-project', + id: project.id, + accountId: project.accountId, + parentId: project.parentId, + path: project.path, + depth: project.depth, + name: project.name, + description: project.description, + metadata: project.metadata, + sortOrder: project.sortOrder, + createdAt: project.createdAt.getTime(), + updatedAt: project.updatedAt.getTime(), + }, + createdAt: Date.now() + }; +} + +export function buildUpdateProjectUpdate( + projectId: string, + updateSeq: number, + updateId: string, + updates: { + name?: string; + description?: string | null; + metadata?: unknown; + sortOrder?: number; + } +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'update-project', + id: projectId, + ...updates, + }, + createdAt: Date.now() + }; +} + +export function buildMoveProjectUpdate( + projectId: string, + newParentId: string | null, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'move-project', + id: projectId, + parentId: newParentId, + }, + createdAt: Date.now() + }; +} + +export function buildDeleteProjectUpdate( + projectId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'delete-project', + projectId, + }, + createdAt: Date.now() + }; +} + +// === ROOM EVENT BUILDERS === + +export function buildNewRoomUpdate(room: RoomData, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-room', + id: room.id, + accountId: room.accountId, + projectId: room.projectId, + name: room.name, + description: room.description, + metadata: room.metadata, + status: room.status, + createdAt: room.createdAt.getTime(), + updatedAt: room.updatedAt.getTime(), + }, + createdAt: Date.now() + }; +} + +export function buildUpdateRoomUpdate( + roomId: string, + projectId: string, + updateSeq: number, + updateId: string, + updates: { + name?: string; + description?: string | null; + metadata?: unknown; + status?: string; + } +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'update-room', + id: roomId, + projectId, + ...updates, + }, + createdAt: Date.now() + }; +} + +export function buildDeleteRoomUpdate( + roomId: string, + projectId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'delete-room', + roomId, + projectId, + }, + createdAt: Date.now() + }; +} diff --git a/sources/app/wap/types.ts b/sources/app/wap/types.ts new file mode 100644 index 00000000..3403a677 --- /dev/null +++ b/sources/app/wap/types.ts @@ -0,0 +1,95 @@ +// WAP (Docker Management Platform) type definitions + +// === Container Types === + +export interface WapContainer { + id: string; + name: string; + image: string; + status: string; // "running", "stopped", "created", "exited", etc. + labels: Record; + createdAt: string; + startedAt?: string; + stoppedAt?: string; + exitCode?: number; + ports?: WapPortBinding[]; + network?: string; +} + +export interface WapPortBinding { + containerPort: number; + hostPort?: number; + protocol: string; // "tcp" | "udp" +} + +// === Template Types === + +export interface WapTemplate { + id: string; + name: string; + description?: string; + image: string; + tag: string; + config: WapTemplateConfig; + category?: string; +} + +export interface WapTemplateConfig { + env?: string[]; + ports?: WapPortBinding[]; + volumes?: string[]; + network?: string; + restartPolicy?: string; + resources?: ContainerResources; + labels?: Record; +} + +// === Container Creation === + +export interface CreateContainerRequest { + name: string; + image: string; + tag?: string; + env?: string[]; + network?: string; + labels?: Record; + restartPolicy?: string; + resources?: ContainerResources; + ports?: WapPortBinding[]; + volumes?: string[]; +} + +export interface ContainerResources { + cpuLimit?: number; // CPU cores (e.g., 2.0) + memoryLimit?: string; // e.g., "2g", "512m" + cpuReserve?: number; + memoryReserve?: string; +} + +// === API Responses === + +export interface WapCreateContainerResponse { + id: string; + name: string; + image: string; + status: string; +} + +export interface WapContainerListResponse { + containers: WapContainer[]; +} + +export interface WapTemplateListResponse { + applications: WapTemplate[]; +} + +// === SSE Event Types === + +export interface WapContainerEvent { + type: 'create' | 'start' | 'stop' | 'die' | 'destroy' | 'restart'; + containerId: string; + containerName: string; + labels: Record; + timestamp: number; + exitCode?: number; +} diff --git a/sources/app/wap/wapClient.ts b/sources/app/wap/wapClient.ts new file mode 100644 index 00000000..665e5774 --- /dev/null +++ b/sources/app/wap/wapClient.ts @@ -0,0 +1,141 @@ +import { log, warn, error as logError } from "@/utils/log"; +import type { + WapContainer, + WapTemplate, + CreateContainerRequest, + WapCreateContainerResponse, + WapContainerListResponse, + WapTemplateListResponse, +} from "./types"; + +const MODULE = 'wap-client'; + +class WapClient { + private baseUrl: string; + private authHeader: string; + + constructor() { + this.baseUrl = process.env.WAP_API_URL || 'https://wap.dev.ai-armory.com'; + const user = process.env.WAP_AUTH_USER || ''; + const pass = process.env.WAP_AUTH_PASS || ''; + this.authHeader = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); + } + + // === Container Operations === + + async createContainer(params: CreateContainerRequest): Promise { + log({ module: MODULE }, `Creating container: ${params.name} (${params.image})`); + return this.request('POST', '/api/v1/containers/create', params); + } + + async startContainer(id: string): Promise { + log({ module: MODULE }, `Starting container: ${id}`); + await this.request('POST', `/api/v1/containers/${id}/start`); + } + + async stopContainer(id: string): Promise { + log({ module: MODULE }, `Stopping container: ${id}`); + await this.request('POST', `/api/v1/containers/${id}/stop`); + } + + async restartContainer(id: string): Promise { + log({ module: MODULE }, `Restarting container: ${id}`); + await this.request('POST', `/api/v1/containers/${id}/restart`); + } + + async removeContainer(id: string): Promise { + log({ module: MODULE }, `Removing container: ${id}`); + await this.request('DELETE', `/api/v1/containers/${id}`); + } + + async getContainer(id: string): Promise { + return this.request('GET', `/api/v1/containers/${id}`); + } + + async listContainers(labelFilter?: string): Promise { + const query = labelFilter ? `?label=${encodeURIComponent(labelFilter)}` : ''; + const response = await this.request('GET', `/api/v1/containers${query}`); + return response.containers || []; + } + + // === Template Operations === + + async listTemplates(): Promise { + const response = await this.request('GET', '/api/v1/applications'); + return response.applications || []; + } + + async getTemplate(id: string): Promise { + return this.request('GET', `/api/v1/applications/${id}`); + } + + // === SSE Connection === + + /** + * Returns the SSE events URL for connecting to real-time Docker events. + */ + getSseUrl(): string { + return `${this.baseUrl}/sse/events`; + } + + getAuthHeader(): string { + return this.authHeader; + } + + // === Private Request Helper === + + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + 'Authorization': this.authHeader, + 'Accept': 'application/json', + }; + + const init: RequestInit = { method, headers }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + let response: Response; + try { + response = await fetch(url, init); + } catch (err) { + logError({ module: MODULE, url, method }, `WAP request failed: ${err}`); + throw new Error(`WAP request failed: ${err}`); + } + + if (!response.ok) { + let errorBody = ''; + try { + errorBody = await response.text(); + } catch { + // ignore + } + const msg = `WAP API error ${response.status} ${method} ${path}: ${errorBody}`; + logError({ module: MODULE, status: response.status, url }, msg); + throw new Error(msg); + } + + // Some endpoints (start, stop, restart, remove) may return no body + const text = await response.text(); + if (!text) { + return undefined as T; + } + + try { + return JSON.parse(text) as T; + } catch { + warn({ module: MODULE }, `WAP response not JSON for ${method} ${path}`); + return text as unknown as T; + } + } + + isConfigured(): boolean { + return !!(process.env.WAP_API_URL && process.env.WAP_AUTH_USER && process.env.WAP_AUTH_PASS); + } +} + +export const wapClient = new WapClient(); diff --git a/sources/app/wap/wapEventListener.ts b/sources/app/wap/wapEventListener.ts new file mode 100644 index 00000000..62a51d4b --- /dev/null +++ b/sources/app/wap/wapEventListener.ts @@ -0,0 +1,214 @@ +import { db } from "@/storage/db"; +import { log, warn, error as logError } from "@/utils/log"; +import { wapClient } from "./wapClient"; +import { eventRouter } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { buildContainerStatusUpdate } from "@/app/events/containerEvents"; +import type { WapContainerEvent } from "./types"; + +const MODULE = 'wap-events'; + +let abortController: AbortController | null = null; +let reconnectTimer: ReturnType | null = null; + +/** + * Connects to WAP's SSE endpoint for real-time Docker lifecycle events. + * Complements the sync service with immediate status updates. + */ +async function connectSse(): Promise { + const sseUrl = wapClient.getSseUrl(); + const authHeader = wapClient.getAuthHeader(); + + abortController = new AbortController(); + + try { + log({ module: MODULE }, `Connecting to WAP SSE: ${sseUrl}`); + + const response = await fetch(sseUrl, { + headers: { + 'Authorization': authHeader, + 'Accept': 'text/event-stream', + }, + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`WAP SSE connection failed: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error('WAP SSE response has no body'); + } + + log({ module: MODULE }, 'Connected to WAP SSE'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + log({ module: MODULE }, 'WAP SSE stream ended'); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE messages + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + let eventData = ''; + for (const line of lines) { + if (line.startsWith('data:')) { + eventData += line.slice(5).trim(); + } else if (line === '' && eventData) { + // End of SSE message + try { + const event = JSON.parse(eventData) as WapContainerEvent; + await handleWapEvent(event); + } catch (err) { + warn({ module: MODULE }, `Failed to parse SSE event: ${err}`); + } + eventData = ''; + } + } + } + } catch (err: any) { + if (err.name === 'AbortError') { + log({ module: MODULE }, 'WAP SSE connection aborted'); + return; + } + logError({ module: MODULE }, `WAP SSE error: ${err}`); + } + + // Reconnect after delay + scheduleReconnect(); +} + +async function handleWapEvent(event: WapContainerEvent): Promise { + // Only process events for forge-managed containers + const launchId = event.labels?.['forge.launch.id']; + if (!launchId) { + return; // Not a forge container + } + + log({ module: MODULE, launchId, eventType: event.type }, `WAP event: ${event.type}`); + + const launch = await db.containerLaunch.findUnique({ + where: { id: launchId } + }); + + if (!launch) { + warn({ module: MODULE, launchId }, 'Received WAP event for unknown launch ID'); + return; + } + + let newStatus: string | null = null; + let statusMessage: string | null = null; + const updateData: any = { lastSeenAt: new Date() }; + + switch (event.type) { + case 'start': + newStatus = 'running'; + updateData.status = 'running'; + updateData.startedAt = launch.startedAt || new Date(); + updateData.wapContainerId = event.containerId || launch.wapContainerId; + break; + + case 'stop': + newStatus = 'stopped'; + updateData.status = 'stopped'; + updateData.stoppedAt = new Date(); + break; + + case 'die': + newStatus = 'error'; + statusMessage = event.exitCode !== undefined ? `Exit code: ${event.exitCode}` : 'Container died'; + updateData.status = 'error'; + updateData.statusMessage = statusMessage; + updateData.stoppedAt = new Date(); + break; + + case 'destroy': + newStatus = 'removed'; + updateData.status = 'removed'; + break; + + case 'restart': + newStatus = 'running'; + updateData.status = 'running'; + updateData.startedAt = new Date(); + break; + + case 'create': + // Container created but not yet started + if (launch.status === 'pending') { + updateData.status = 'creating'; + updateData.wapContainerId = event.containerId || launch.wapContainerId; + newStatus = 'creating'; + } + break; + + default: + return; + } + + await db.containerLaunch.update({ + where: { id: launchId }, + data: updateData + }); + + if (newStatus) { + try { + const updSeq = await allocateUserSeq(launch.accountId); + const payload = buildContainerStatusUpdate( + launchId, newStatus, statusMessage, launch.wapContainerId, launch.machineId, + updSeq, randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: launch.accountId, + payload, + recipientFilter: { type: 'user-scoped-only' } + }); + } catch (err) { + logError({ module: MODULE }, `Failed to emit status update: ${err}`); + } + } +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = 10_000; // 10 seconds + log({ module: MODULE }, `Reconnecting to WAP SSE in ${delay}ms`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectSse(); + }, delay); +} + +// === Startup / Shutdown === + +export function startWapEventListener(): void { + if (!wapClient.isConfigured()) { + warn({ module: MODULE }, 'WAP not configured. SSE listener disabled.'); + return; + } + + log({ module: MODULE }, 'Starting WAP event listener'); + connectSse(); +} + +export function stopWapEventListener(): void { + if (abortController) { + abortController.abort(); + abortController = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + log({ module: MODULE }, 'WAP event listener stopped'); +} diff --git a/sources/app/wap/wapSync.ts b/sources/app/wap/wapSync.ts new file mode 100644 index 00000000..d1436563 --- /dev/null +++ b/sources/app/wap/wapSync.ts @@ -0,0 +1,237 @@ +import { db } from "@/storage/db"; +import { log, warn, error as logError } from "@/utils/log"; +import { wapClient } from "./wapClient"; +import { eventRouter } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { buildContainerStatusUpdate } from "@/app/events/containerEvents"; +import type { WapContainer } from "./types"; + +const MODULE = 'wap-sync'; + +interface WapSyncOptions { + containerIntervalMs?: number; + templateIntervalMs?: number; +} + +let containerTimer: ReturnType | null = null; +let templateTimer: ReturnType | null = null; + +// === Container Sync === + +async function syncContainers(): Promise { + try { + // Get all containers from WAP that have forge labels + const wapContainers = await wapClient.listContainers('forge.container.type'); + + // Build a map of WAP container IDs for fast lookup + const wapMap = new Map(); + for (const wc of wapContainers) { + if (wc.id) { + wapMap.set(wc.id, wc); + } + // Also index by launch ID label + const launchId = wc.labels?.['forge.launch.id']; + if (launchId) { + wapMap.set(`launch:${launchId}`, wc); + } + } + + // Find all active ContainerLaunch records + const activeLaunches = await db.containerLaunch.findMany({ + where: { + status: { in: ['running', 'starting', 'creating'] } + } + }); + + for (const launch of activeLaunches) { + const wapContainer = launch.wapContainerId + ? wapMap.get(launch.wapContainerId) + : wapMap.get(`launch:${launch.id}`); + + if (wapContainer) { + const wapStatus = wapContainer.status?.toLowerCase(); + + if (wapStatus === 'running') { + // Container is running in WAP — update lastSeenAt + if (launch.status !== 'running') { + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { + status: 'running', + lastSeenAt: new Date(), + startedAt: launch.startedAt || new Date(), + } + }); + await emitStatusUpdate(launch.accountId, launch.id, 'running', null, launch.wapContainerId, launch.machineId); + } else { + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { lastSeenAt: new Date() } + }); + } + } else if (wapStatus === 'exited' || wapStatus === 'stopped') { + // Container stopped + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { + status: 'stopped', + stoppedAt: new Date(), + lastSeenAt: new Date(), + } + }); + await emitStatusUpdate(launch.accountId, launch.id, 'stopped', null, launch.wapContainerId, launch.machineId); + } + } else { + // Container not found in WAP — mark as offline + await db.containerLaunch.update({ + where: { id: launch.id }, + data: { status: 'offline' } + }); + await emitStatusUpdate(launch.accountId, launch.id, 'offline', 'Container not found in WAP', launch.wapContainerId, launch.machineId); + warn({ module: MODULE, launchId: launch.id }, 'Container not found in WAP, marked offline'); + } + } + + log({ module: MODULE }, `Container sync complete. Checked ${activeLaunches.length} active launches against ${wapContainers.length} WAP containers`); + } catch (err) { + logError({ module: MODULE }, `Container sync failed: ${err}`); + } +} + +// === Template Sync === + +async function syncTemplates(): Promise { + try { + const wapTemplates = await wapClient.listTemplates(); + const seenIds = new Set(); + + for (const tmpl of wapTemplates) { + seenIds.add(tmpl.id); + + // Check if template config includes ai-forge-client indicators + const isForgeReady = isTemplateForgeReady(tmpl); + + await db.wapTemplateCache.upsert({ + where: { wapTemplateId: tmpl.id }, + create: { + wapTemplateId: tmpl.id, + name: tmpl.name, + description: tmpl.description || null, + image: tmpl.image, + tag: tmpl.tag || 'latest', + config: tmpl.config as any, + category: tmpl.category || null, + isForgeReady, + lastSyncedAt: new Date(), + }, + update: { + name: tmpl.name, + description: tmpl.description || null, + image: tmpl.image, + tag: tmpl.tag || 'latest', + config: tmpl.config as any, + category: tmpl.category || null, + isForgeReady, + lastSyncedAt: new Date(), + } + }); + } + + // Remove stale entries not seen in latest sync + if (seenIds.size > 0) { + await db.wapTemplateCache.deleteMany({ + where: { + wapTemplateId: { notIn: Array.from(seenIds) } + } + }); + } + + log({ module: MODULE }, `Template sync complete. ${wapTemplates.length} templates synced`); + } catch (err) { + logError({ module: MODULE }, `Template sync failed: ${err}`); + } +} + +function isTemplateForgeReady(tmpl: { config?: any; image?: string }): boolean { + // Check if env vars reference forge-client or if image name suggests forge compatibility + const envVars = tmpl.config?.env as string[] | undefined; + if (envVars) { + for (const env of envVars) { + if (env.startsWith('FORGE_MACHINE_TOKEN=') || env.startsWith('FORGE_SERVER_URL=')) { + return true; + } + } + } + // Check labels + const labels = tmpl.config?.labels as Record | undefined; + if (labels?.['forge.ready'] === 'true') { + return true; + } + return false; +} + +// === Status Event Emission === + +async function emitStatusUpdate( + accountId: string, + launchId: string, + status: string, + statusMessage: string | null, + wapContainerId: string | null, + machineId: string | null, +): Promise { + try { + const updSeq = await allocateUserSeq(accountId); + const payload = buildContainerStatusUpdate( + launchId, status, statusMessage, wapContainerId, machineId, + updSeq, randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: accountId, + payload, + recipientFilter: { type: 'user-scoped-only' } + }); + } catch (err) { + logError({ module: MODULE }, `Failed to emit status update: ${err}`); + } +} + +// === Startup / Shutdown === + +export function startWapSync(options: WapSyncOptions = {}): void { + if (!wapClient.isConfigured()) { + warn({ module: MODULE }, 'WAP not configured (missing WAP_API_URL/WAP_AUTH_USER/WAP_AUTH_PASS). Sync disabled.'); + return; + } + + const containerInterval = options.containerIntervalMs || 30_000; + const templateInterval = options.templateIntervalMs || 300_000; + + log({ module: MODULE }, `Starting WAP sync (containers: ${containerInterval}ms, templates: ${templateInterval}ms)`); + + // Run initial sync after a short delay + setTimeout(() => { + syncContainers(); + syncTemplates(); + }, 5_000); + + // Set up recurring sync + containerTimer = setInterval(syncContainers, containerInterval); + templateTimer = setInterval(syncTemplates, templateInterval); +} + +export function stopWapSync(): void { + if (containerTimer) { + clearInterval(containerTimer); + containerTimer = null; + } + if (templateTimer) { + clearInterval(templateTimer); + templateTimer = null; + } + log({ module: MODULE }, 'WAP sync stopped'); +} + +// Export for manual trigger from API +export { syncContainers, syncTemplates }; diff --git a/sources/main.ts b/sources/main.ts index 31315a78..ef441b5d 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -11,6 +11,8 @@ import { startDatabaseMetricsUpdater } from "@/app/monitoring/metrics2"; import { initEncrypt } from "./modules/encrypt"; import { initGithub } from "./modules/github"; import { loadFiles } from "./storage/files"; +import { startWapSync, stopWapSync } from "./app/wap/wapSync"; +import { startWapEventListener, stopWapEventListener } from "./app/wap/wapEventListener"; async function main() { @@ -39,6 +41,14 @@ async function main() { startDatabaseMetricsUpdater(); startTimeout(); + // WAP container orchestration services + startWapSync({ containerIntervalMs: 30_000, templateIntervalMs: 300_000 }); + startWapEventListener(); + onShutdown('wap', async () => { + stopWapSync(); + stopWapEventListener(); + }); + // // Ready //