feat: add access keys

This commit is contained in:
Steve Korshakov 2025-09-16 22:50:47 -07:00
parent 86bf8bf03c
commit fbd8e57ed6
6 changed files with 440 additions and 3 deletions

View File

@ -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;

View File

@ -45,6 +45,7 @@ model Account {
UploadedFile UploadedFile[] UploadedFile UploadedFile[]
ServiceAccountToken ServiceAccountToken[] ServiceAccountToken ServiceAccountToken[]
Artifact Artifact[] Artifact Artifact[]
AccessKey AccessKey[]
} }
model TerminalAuthRequest { model TerminalAuthRequest {
@ -100,6 +101,7 @@ model Session {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
messages SessionMessage[] messages SessionMessage[]
usageReports UsageReport[] usageReports UsageReport[]
accessKeys AccessKey[]
@@unique([accountId, tag]) @@unique([accountId, tag])
@@index([accountId, updatedAt(sort: Desc)]) @@index([accountId, updatedAt(sort: Desc)])
@ -204,9 +206,10 @@ model Machine {
dataEncryptionKey Bytes? dataEncryptionKey Bytes?
seq Int @default(0) seq Int @default(0)
active Boolean @default(true) active Boolean @default(true)
lastActiveAt DateTime @default(now()) lastActiveAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accessKeys AccessKey[]
@@unique([accountId, id]) @@unique([accountId, id])
@@index([accountId]) @@index([accountId])
@ -263,3 +266,26 @@ model Artifact {
@@index([accountId]) @@index([accountId])
@@index([accountId, updatedAt(sort: Desc)]) @@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])
}

View File

@ -15,6 +15,7 @@ import { devRoutes } from "./routes/devRoutes";
import { versionRoutes } from "./routes/versionRoutes"; import { versionRoutes } from "./routes/versionRoutes";
import { voiceRoutes } from "./routes/voiceRoutes"; import { voiceRoutes } from "./routes/voiceRoutes";
import { artifactsRoutes } from "./routes/artifactsRoutes"; import { artifactsRoutes } from "./routes/artifactsRoutes";
import { accessKeysRoutes } from "./routes/accessKeysRoutes";
import { enableMonitoring } from "./utils/enableMonitoring"; import { enableMonitoring } from "./utils/enableMonitoring";
import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableErrorHandlers } from "./utils/enableErrorHandlers";
import { enableAuthentication } from "./utils/enableAuthentication"; import { enableAuthentication } from "./utils/enableAuthentication";
@ -56,6 +57,7 @@ export async function startApi(eventRouter: EventRouter) {
connectRoutes(typed, eventRouter); connectRoutes(typed, eventRouter);
machinesRoutes(typed, eventRouter); machinesRoutes(typed, eventRouter);
artifactsRoutes(typed, eventRouter); artifactsRoutes(typed, eventRouter);
accessKeysRoutes(typed, eventRouter);
devRoutes(typed); devRoutes(typed);
versionRoutes(typed); versionRoutes(typed);
voiceRoutes(typed); voiceRoutes(typed);

View File

@ -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'
});
}
});
}

View File

@ -11,6 +11,7 @@ import { pingHandler } from "./socket/pingHandler";
import { sessionUpdateHandler } from "./socket/sessionUpdateHandler"; import { sessionUpdateHandler } from "./socket/sessionUpdateHandler";
import { machineUpdateHandler } from "./socket/machineUpdateHandler"; import { machineUpdateHandler } from "./socket/machineUpdateHandler";
import { artifactUpdateHandler } from "./socket/artifactUpdateHandler"; import { artifactUpdateHandler } from "./socket/artifactUpdateHandler";
import { accessKeyHandler } from "./socket/accessKeyHandler";
export function startSocket(app: Fastify, eventRouter: EventRouter) { export function startSocket(app: Fastify, eventRouter: EventRouter) {
const io = new Server(app.server, { const io = new Server(app.server, {
@ -142,6 +143,7 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) {
pingHandler(socket); pingHandler(socket);
machineUpdateHandler(userId, socket, eventRouter); machineUpdateHandler(userId, socket, eventRouter);
artifactUpdateHandler(userId, socket, eventRouter); artifactUpdateHandler(userId, socket, eventRouter);
accessKeyHandler(userId, socket, eventRouter);
// Ready // Ready
log({ module: 'websocket' }, `User connected: ${userId}`); log({ module: 'websocket' }, `User connected: ${userId}`);

View File

@ -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'
});
}
}
});
}