diff --git a/prisma/migrations/20250917055002_add_access_key/migration.sql b/prisma/migrations/20250917055002_add_access_key/migration.sql new file mode 100644 index 0000000..89aaa43 --- /dev/null +++ b/prisma/migrations/20250917055002_add_access_key/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "AccessKey" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "machineId" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "data" TEXT NOT NULL, + "dataVersion" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AccessKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AccessKey_accountId_idx" ON "AccessKey"("accountId"); + +-- CreateIndex +CREATE INDEX "AccessKey_sessionId_idx" ON "AccessKey"("sessionId"); + +-- CreateIndex +CREATE INDEX "AccessKey_machineId_idx" ON "AccessKey"("machineId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessKey_accountId_machineId_sessionId_key" ON "AccessKey"("accountId", "machineId", "sessionId"); + +-- AddForeignKey +ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_accountId_machineId_fkey" FOREIGN KEY ("accountId", "machineId") REFERENCES "Machine"("accountId", "id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccessKey" ADD CONSTRAINT "AccessKey_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0b7be0..d97d29e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model Account { UploadedFile UploadedFile[] ServiceAccountToken ServiceAccountToken[] Artifact Artifact[] + AccessKey AccessKey[] } model TerminalAuthRequest { @@ -100,6 +101,7 @@ model Session { updatedAt DateTime @updatedAt messages SessionMessage[] usageReports UsageReport[] + accessKeys AccessKey[] @@unique([accountId, tag]) @@index([accountId, updatedAt(sort: Desc)]) @@ -204,9 +206,10 @@ model Machine { dataEncryptionKey Bytes? seq Int @default(0) active Boolean @default(true) - lastActiveAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessKeys AccessKey[] @@unique([accountId, id]) @@index([accountId]) @@ -263,3 +266,26 @@ model Artifact { @@index([accountId]) @@index([accountId, updatedAt(sort: Desc)]) } + +// +// Access Keys +// + +model AccessKey { + id String @id @default(cuid()) + accountId String + account Account @relation(fields: [accountId], references: [id]) + machineId String + machine Machine @relation(fields: [accountId, machineId], references: [accountId, id]) + sessionId String + session Session @relation(fields: [sessionId], references: [id]) + data String // Encrypted data + dataVersion Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accountId, machineId, sessionId]) + @@index([accountId]) + @@index([sessionId]) + @@index([machineId]) +} diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 63f627c..37e0b78 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -15,6 +15,7 @@ import { devRoutes } from "./routes/devRoutes"; import { versionRoutes } from "./routes/versionRoutes"; import { voiceRoutes } from "./routes/voiceRoutes"; import { artifactsRoutes } from "./routes/artifactsRoutes"; +import { accessKeysRoutes } from "./routes/accessKeysRoutes"; import { enableMonitoring } from "./utils/enableMonitoring"; import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; @@ -56,6 +57,7 @@ export async function startApi(eventRouter: EventRouter) { connectRoutes(typed, eventRouter); machinesRoutes(typed, eventRouter); artifactsRoutes(typed, eventRouter); + accessKeysRoutes(typed, eventRouter); devRoutes(typed); versionRoutes(typed); voiceRoutes(typed); diff --git a/sources/app/api/routes/accessKeysRoutes.ts b/sources/app/api/routes/accessKeysRoutes.ts new file mode 100644 index 0000000..2afd732 --- /dev/null +++ b/sources/app/api/routes/accessKeysRoutes.ts @@ -0,0 +1,290 @@ +import { Fastify } from "../types"; +import { z } from "zod"; +import { db } from "@/storage/db"; +import { log } from "@/utils/log"; +import { EventRouter } from "@/app/events/eventRouter"; + +export function accessKeysRoutes(app: Fastify, eventRouter: EventRouter) { + // Get Access Key API + app.get('/v1/access-keys/:sessionId/:machineId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + machineId: z.string() + }), + response: { + 200: z.object({ + accessKey: z.object({ + data: z.string(), + dataVersion: z.number(), + createdAt: z.number(), + updatedAt: z.number() + }).nullable() + }), + 404: z.object({ + error: z.literal('Session or machine not found') + }), + 500: z.object({ + error: z.literal('Failed to get access key') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, machineId } = request.params; + + try { + // Verify session and machine belong to user + const [session, machine] = await Promise.all([ + db.session.findFirst({ + where: { id: sessionId, accountId: userId } + }), + db.machine.findFirst({ + where: { id: machineId, accountId: userId } + }) + ]); + + if (!session || !machine) { + return reply.code(404).send({ error: 'Session or machine not found' }); + } + + // Get access key + const accessKey = await db.accessKey.findUnique({ + where: { + accountId_machineId_sessionId: { + accountId: userId, + machineId, + sessionId + } + } + }); + + if (!accessKey) { + return reply.send({ accessKey: null }); + } + + return reply.send({ + accessKey: { + data: accessKey.data, + dataVersion: accessKey.dataVersion, + createdAt: accessKey.createdAt.getTime(), + updatedAt: accessKey.updatedAt.getTime() + } + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to get access key: ${error}`); + return reply.code(500).send({ error: 'Failed to get access key' }); + } + }); + + // Create Access Key API + app.post('/v1/access-keys/:sessionId/:machineId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + machineId: z.string() + }), + body: z.object({ + data: z.string() + }), + response: { + 200: z.object({ + success: z.boolean(), + accessKey: z.object({ + data: z.string(), + dataVersion: z.number(), + createdAt: z.number(), + updatedAt: z.number() + }).optional(), + error: z.string().optional() + }), + 404: z.object({ + error: z.literal('Session or machine not found') + }), + 409: z.object({ + error: z.literal('Access key already exists') + }), + 500: z.object({ + error: z.literal('Failed to create access key') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, machineId } = request.params; + const { data } = request.body; + + try { + // Verify session and machine belong to user + const [session, machine] = await Promise.all([ + db.session.findFirst({ + where: { id: sessionId, accountId: userId } + }), + db.machine.findFirst({ + where: { id: machineId, accountId: userId } + }) + ]); + + if (!session || !machine) { + return reply.code(404).send({ error: 'Session or machine not found' }); + } + + // Check if access key already exists + const existing = await db.accessKey.findUnique({ + where: { + accountId_machineId_sessionId: { + accountId: userId, + machineId, + sessionId + } + } + }); + + if (existing) { + return reply.code(409).send({ error: 'Access key already exists' }); + } + + // Create access key + const accessKey = await db.accessKey.create({ + data: { + accountId: userId, + machineId, + sessionId, + data, + dataVersion: 1 + } + }); + + log({ module: 'access-keys', userId, sessionId, machineId }, 'Created new access key'); + + return reply.send({ + success: true, + accessKey: { + data: accessKey.data, + dataVersion: accessKey.dataVersion, + createdAt: accessKey.createdAt.getTime(), + updatedAt: accessKey.updatedAt.getTime() + } + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to create access key: ${error}`); + return reply.code(500).send({ error: 'Failed to create access key' }); + } + }); + + // Update Access Key API + app.put('/v1/access-keys/:sessionId/:machineId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + machineId: z.string() + }), + body: z.object({ + data: z.string(), + expectedVersion: z.number().int().min(0) + }), + response: { + 200: z.union([ + z.object({ + success: z.literal(true), + version: z.number() + }), + z.object({ + success: z.literal(false), + error: z.literal('version-mismatch'), + currentVersion: z.number(), + currentData: z.string() + }) + ]), + 404: z.object({ + error: z.literal('Access key not found') + }), + 500: z.object({ + success: z.literal(false), + error: z.literal('Failed to update access key') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, machineId } = request.params; + const { data, expectedVersion } = request.body; + + try { + // Get current access key for version check + const currentAccessKey = await db.accessKey.findUnique({ + where: { + accountId_machineId_sessionId: { + accountId: userId, + machineId, + sessionId + } + } + }); + + if (!currentAccessKey) { + return reply.code(404).send({ error: 'Access key not found' }); + } + + // Check version + if (currentAccessKey.dataVersion !== expectedVersion) { + return reply.code(200).send({ + success: false, + error: 'version-mismatch', + currentVersion: currentAccessKey.dataVersion, + currentData: currentAccessKey.data + }); + } + + // Update with version check + const { count } = await db.accessKey.updateMany({ + where: { + accountId: userId, + machineId, + sessionId, + dataVersion: expectedVersion + }, + data: { + data, + dataVersion: expectedVersion + 1, + updatedAt: new Date() + } + }); + + if (count === 0) { + // Re-fetch to get current version + const accessKey = await db.accessKey.findUnique({ + where: { + accountId_machineId_sessionId: { + accountId: userId, + machineId, + sessionId + } + } + }); + return reply.code(200).send({ + success: false, + error: 'version-mismatch', + currentVersion: accessKey?.dataVersion || 0, + currentData: accessKey?.data || '' + }); + } + + log({ module: 'access-keys', userId, sessionId, machineId }, `Updated access key to version ${expectedVersion + 1}`); + + return reply.send({ + success: true, + version: expectedVersion + 1 + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to update access key: ${error}`); + return reply.code(500).send({ + success: false, + error: 'Failed to update access key' + }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/socket.ts b/sources/app/api/socket.ts index 382af2c..b2f395b 100644 --- a/sources/app/api/socket.ts +++ b/sources/app/api/socket.ts @@ -11,6 +11,7 @@ import { pingHandler } from "./socket/pingHandler"; import { sessionUpdateHandler } from "./socket/sessionUpdateHandler"; import { machineUpdateHandler } from "./socket/machineUpdateHandler"; import { artifactUpdateHandler } from "./socket/artifactUpdateHandler"; +import { accessKeyHandler } from "./socket/accessKeyHandler"; export function startSocket(app: Fastify, eventRouter: EventRouter) { const io = new Server(app.server, { @@ -142,6 +143,7 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) { pingHandler(socket); machineUpdateHandler(userId, socket, eventRouter); artifactUpdateHandler(userId, socket, eventRouter); + accessKeyHandler(userId, socket, eventRouter); // Ready log({ module: 'websocket' }, `User connected: ${userId}`); diff --git a/sources/app/api/socket/accessKeyHandler.ts b/sources/app/api/socket/accessKeyHandler.ts new file mode 100644 index 0000000..b8c3b52 --- /dev/null +++ b/sources/app/api/socket/accessKeyHandler.ts @@ -0,0 +1,83 @@ +import { Socket } from "socket.io"; +import { db } from "@/storage/db"; +import { log } from "@/utils/log"; +import { EventRouter } from "@/app/events/eventRouter"; + +export function accessKeyHandler(userId: string, socket: Socket, eventRouter: EventRouter) { + // Get access key via socket + socket.on('access-key-get', async (data: { sessionId: string; machineId: string }, callback: (response: any) => void) => { + try { + const { sessionId, machineId } = data; + + if (!sessionId || !machineId) { + if (callback) { + callback({ + ok: false, + error: 'Invalid parameters: sessionId and machineId are required' + }); + } + return; + } + + // Verify session and machine belong to user + const [session, machine] = await Promise.all([ + db.session.findFirst({ + where: { id: sessionId, accountId: userId } + }), + db.machine.findFirst({ + where: { id: machineId, accountId: userId } + }) + ]); + + if (!session || !machine) { + if (callback) { + callback({ + ok: false, + error: 'Session or machine not found' + }); + } + return; + } + + // Get access key + const accessKey = await db.accessKey.findUnique({ + where: { + accountId_machineId_sessionId: { + accountId: userId, + machineId, + sessionId + } + } + }); + + if (callback) { + if (accessKey) { + callback({ + ok: true, + accessKey: { + data: accessKey.data, + dataVersion: accessKey.dataVersion, + createdAt: accessKey.createdAt.getTime(), + updatedAt: accessKey.updatedAt.getTime() + } + }); + } else { + callback({ + ok: true, + accessKey: null + }); + } + } + + log({ module: 'websocket-access-key' }, `Access key retrieved for session ${sessionId}, machine ${machineId}`); + } catch (error) { + log({ module: 'websocket', level: 'error' }, `Error in access-key-get: ${error}`); + if (callback) { + callback({ + ok: false, + error: 'Internal error' + }); + } + } + }); +} \ No newline at end of file