diff --git a/prisma/migrations/20250917052000_add_artefacts/migration.sql b/prisma/migrations/20250917052000_add_artefacts/migration.sql new file mode 100644 index 0000000..143b692 --- /dev/null +++ b/prisma/migrations/20250917052000_add_artefacts/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "Artifact" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "header" BYTEA NOT NULL, + "headerVersion" INTEGER NOT NULL DEFAULT 0, + "body" BYTEA NOT NULL, + "bodyVersion" INTEGER NOT NULL DEFAULT 0, + "dataEncryptionKey" BYTEA NOT NULL, + "seq" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Artifact_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Artifact_accountId_idx" ON "Artifact"("accountId"); + +-- CreateIndex +CREATE INDEX "Artifact_accountId_updatedAt_idx" ON "Artifact"("accountId", "updatedAt" DESC); + +-- AddForeignKey +ALTER TABLE "Artifact" ADD CONSTRAINT "Artifact_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a0553c..c0b7be0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model Account { Machine Machine[] UploadedFile UploadedFile[] ServiceAccountToken ServiceAccountToken[] + Artifact Artifact[] } model TerminalAuthRequest { @@ -241,3 +242,24 @@ model ServiceAccountToken { @@unique([accountId, vendor]) @@index([accountId]) } + +// +// Artifacts +// + +model Artifact { + id String @id // UUID provided by client + accountId String + account Account @relation(fields: [accountId], references: [id]) + header Bytes // Encrypted header (can contain JSON) + headerVersion Int @default(0) + body Bytes // Encrypted body + bodyVersion Int @default(0) + dataEncryptionKey Bytes // Encryption key for this artifact + seq Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([accountId]) + @@index([accountId, updatedAt(sort: Desc)]) +} diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 8ffcd16..63f627c 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -14,6 +14,7 @@ import { machinesRoutes } from "./routes/machinesRoutes"; import { devRoutes } from "./routes/devRoutes"; import { versionRoutes } from "./routes/versionRoutes"; import { voiceRoutes } from "./routes/voiceRoutes"; +import { artifactsRoutes } from "./routes/artifactsRoutes"; import { enableMonitoring } from "./utils/enableMonitoring"; import { enableErrorHandlers } from "./utils/enableErrorHandlers"; import { enableAuthentication } from "./utils/enableAuthentication"; @@ -54,6 +55,7 @@ export async function startApi(eventRouter: EventRouter) { accountRoutes(typed, eventRouter); connectRoutes(typed, eventRouter); machinesRoutes(typed, eventRouter); + artifactsRoutes(typed, eventRouter); devRoutes(typed); versionRoutes(typed); voiceRoutes(typed); diff --git a/sources/app/api/routes/artifactsRoutes.ts b/sources/app/api/routes/artifactsRoutes.ts new file mode 100644 index 0000000..f27e08e --- /dev/null +++ b/sources/app/api/routes/artifactsRoutes.ts @@ -0,0 +1,414 @@ +import { EventRouter, buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate } from "@/app/events/eventRouter"; +import { db } from "@/storage/db"; +import { Fastify } from "../types"; +import { z } from "zod"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { allocateUserSeq } from "@/storage/seq"; +import { log } from "@/utils/log"; +import * as privacyKit from "privacy-kit"; + +export function artifactsRoutes(app: Fastify, eventRouter: EventRouter) { + // GET /v1/artifacts - List all artifacts for the account + app.get('/v1/artifacts', { + preHandler: app.authenticate, + schema: { + response: { + 200: z.array(z.object({ + id: z.string(), + header: z.string(), + headerVersion: z.number(), + dataEncryptionKey: z.string(), + seq: z.number(), + createdAt: z.number(), + updatedAt: z.number() + })), + 500: z.object({ + error: z.literal('Failed to get artifacts') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + + try { + const artifacts = await db.artifact.findMany({ + where: { accountId: userId }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + header: true, + headerVersion: true, + dataEncryptionKey: true, + seq: true, + createdAt: true, + updatedAt: true + } + }); + + return reply.send(artifacts.map(a => ({ + id: a.id, + header: privacyKit.encodeBase64(a.header), + headerVersion: a.headerVersion, + dataEncryptionKey: privacyKit.encodeBase64(a.dataEncryptionKey), + seq: a.seq, + createdAt: a.createdAt.getTime(), + updatedAt: a.updatedAt.getTime() + }))); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to get artifacts: ${error}`); + return reply.code(500).send({ error: 'Failed to get artifacts' }); + } + }); + + // GET /v1/artifacts/:id - Get single artifact with full body + app.get('/v1/artifacts/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + id: z.string(), + header: z.string(), + headerVersion: z.number(), + body: z.string(), + bodyVersion: z.number(), + dataEncryptionKey: z.string(), + seq: z.number(), + createdAt: z.number(), + updatedAt: z.number() + }), + 404: z.object({ + error: z.literal('Artifact not found') + }), + 500: z.object({ + error: z.literal('Failed to get artifact') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + try { + const artifact = await db.artifact.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!artifact) { + return reply.code(404).send({ error: 'Artifact not found' }); + } + + return reply.send({ + id: artifact.id, + header: privacyKit.encodeBase64(artifact.header), + headerVersion: artifact.headerVersion, + body: privacyKit.encodeBase64(artifact.body), + bodyVersion: artifact.bodyVersion, + dataEncryptionKey: privacyKit.encodeBase64(artifact.dataEncryptionKey), + seq: artifact.seq, + createdAt: artifact.createdAt.getTime(), + updatedAt: artifact.updatedAt.getTime() + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to get artifact: ${error}`); + return reply.code(500).send({ error: 'Failed to get artifact' }); + } + }); + + // POST /v1/artifacts - Create new artifact + app.post('/v1/artifacts', { + preHandler: app.authenticate, + schema: { + body: z.object({ + id: z.string().uuid(), + header: z.string(), + body: z.string(), + dataEncryptionKey: z.string() + }), + response: { + 200: z.object({ + id: z.string(), + header: z.string(), + headerVersion: z.number(), + body: z.string(), + bodyVersion: z.number(), + dataEncryptionKey: z.string(), + seq: z.number(), + createdAt: z.number(), + updatedAt: z.number() + }), + 409: z.object({ + error: z.literal('Artifact with this ID already exists for another account') + }), + 500: z.object({ + error: z.literal('Failed to create artifact') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { id, header, body, dataEncryptionKey } = request.body; + + try { + // Check if artifact exists + const existingArtifact = await db.artifact.findUnique({ + where: { id } + }); + + if (existingArtifact) { + // If exists for another account, return conflict + if (existingArtifact.accountId !== userId) { + return reply.code(409).send({ + error: 'Artifact with this ID already exists for another account' + }); + } + + // If exists for same account, return existing (idempotent) + log({ module: 'api', artifactId: id, userId }, 'Found existing artifact'); + return reply.send({ + id: existingArtifact.id, + header: privacyKit.encodeBase64(existingArtifact.header), + headerVersion: existingArtifact.headerVersion, + body: privacyKit.encodeBase64(existingArtifact.body), + bodyVersion: existingArtifact.bodyVersion, + dataEncryptionKey: privacyKit.encodeBase64(existingArtifact.dataEncryptionKey), + seq: existingArtifact.seq, + createdAt: existingArtifact.createdAt.getTime(), + updatedAt: existingArtifact.updatedAt.getTime() + }); + } + + // Create new artifact + log({ module: 'api', artifactId: id, userId }, 'Creating new artifact'); + const artifact = await db.artifact.create({ + data: { + id, + accountId: userId, + header: privacyKit.decodeBase64(header), + headerVersion: 1, + body: privacyKit.decodeBase64(body), + bodyVersion: 1, + dataEncryptionKey: privacyKit.decodeBase64(dataEncryptionKey), + seq: 0 + } + }); + + // Emit new-artifact event + const updSeq = await allocateUserSeq(userId); + const newArtifactPayload = buildNewArtifactUpdate(artifact, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload: newArtifactPayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + id: artifact.id, + header: privacyKit.encodeBase64(artifact.header), + headerVersion: artifact.headerVersion, + body: privacyKit.encodeBase64(artifact.body), + bodyVersion: artifact.bodyVersion, + dataEncryptionKey: privacyKit.encodeBase64(artifact.dataEncryptionKey), + seq: artifact.seq, + createdAt: artifact.createdAt.getTime(), + updatedAt: artifact.updatedAt.getTime() + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to create artifact: ${error}`); + return reply.code(500).send({ error: 'Failed to create artifact' }); + } + }); + + // POST /v1/artifacts/:id - Update artifact with version control + app.post('/v1/artifacts/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + header: z.string().optional(), + expectedHeaderVersion: z.number().int().min(0).optional(), + body: z.string().optional(), + expectedBodyVersion: z.number().int().min(0).optional() + }), + response: { + 200: z.union([ + z.object({ + success: z.literal(true), + headerVersion: z.number().optional(), + bodyVersion: z.number().optional() + }), + z.object({ + success: z.literal(false), + error: z.literal('version-mismatch'), + currentHeaderVersion: z.number().optional(), + currentBodyVersion: z.number().optional(), + currentHeader: z.string().optional(), + currentBody: z.string().optional() + }) + ]), + 404: z.object({ + error: z.literal('Artifact not found') + }), + 500: z.object({ + error: z.literal('Failed to update artifact') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + const { header, expectedHeaderVersion, body, expectedBodyVersion } = request.body; + + try { + // Get current artifact for version check + const currentArtifact = await db.artifact.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!currentArtifact) { + return reply.code(404).send({ error: 'Artifact not found' }); + } + + // Check version mismatches + const headerMismatch = header !== undefined && expectedHeaderVersion !== undefined && + currentArtifact.headerVersion !== expectedHeaderVersion; + const bodyMismatch = body !== undefined && expectedBodyVersion !== undefined && + currentArtifact.bodyVersion !== expectedBodyVersion; + + if (headerMismatch || bodyMismatch) { + return reply.send({ + success: false, + error: 'version-mismatch', + ...(headerMismatch && { + currentHeaderVersion: currentArtifact.headerVersion, + currentHeader: privacyKit.encodeBase64(currentArtifact.header) + }), + ...(bodyMismatch && { + currentBodyVersion: currentArtifact.bodyVersion, + currentBody: privacyKit.encodeBase64(currentArtifact.body) + }) + }); + } + + // Build update data + const updateData: any = { + updatedAt: new Date() + }; + + let headerUpdate: { value: string; version: number } | undefined; + let bodyUpdate: { value: string; version: number } | undefined; + + if (header !== undefined && expectedHeaderVersion !== undefined) { + updateData.header = privacyKit.decodeBase64(header); + updateData.headerVersion = expectedHeaderVersion + 1; + headerUpdate = { + value: header, + version: expectedHeaderVersion + 1 + }; + } + + if (body !== undefined && expectedBodyVersion !== undefined) { + updateData.body = privacyKit.decodeBase64(body); + updateData.bodyVersion = expectedBodyVersion + 1; + bodyUpdate = { + value: body, + version: expectedBodyVersion + 1 + }; + } + + // Increment seq + updateData.seq = currentArtifact.seq + 1; + + // Update artifact + await db.artifact.update({ + where: { id }, + data: updateData + }); + + // Emit update-artifact event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateArtifactUpdate(id, updSeq, randomKeyNaked(12), headerUpdate, bodyUpdate); + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + success: true, + ...(headerUpdate && { headerVersion: headerUpdate.version }), + ...(bodyUpdate && { bodyVersion: bodyUpdate.version }) + }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to update artifact: ${error}`); + return reply.code(500).send({ error: 'Failed to update artifact' }); + } + }); + + // DELETE /v1/artifacts/:id - Delete artifact + app.delete('/v1/artifacts/:id', { + preHandler: app.authenticate, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + success: z.literal(true) + }), + 404: z.object({ + error: z.literal('Artifact not found') + }), + 500: z.object({ + error: z.literal('Failed to delete artifact') + }) + } + } + }, async (request, reply) => { + const userId = request.userId; + const { id } = request.params; + + try { + // Check if artifact exists and belongs to user + const artifact = await db.artifact.findFirst({ + where: { + id, + accountId: userId + } + }); + + if (!artifact) { + return reply.code(404).send({ error: 'Artifact not found' }); + } + + // Delete artifact + await db.artifact.delete({ + where: { id } + }); + + // Emit delete-artifact event + const updSeq = await allocateUserSeq(userId); + const deletePayload = buildDeleteArtifactUpdate(id, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload: deletePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ success: true }); + } catch (error) { + log({ module: 'api', level: 'error' }, `Failed to delete artifact: ${error}`); + return reply.code(500).send({ error: 'Failed to delete artifact' }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/socket.ts b/sources/app/api/socket.ts index 1af2ff4..382af2c 100644 --- a/sources/app/api/socket.ts +++ b/sources/app/api/socket.ts @@ -10,6 +10,7 @@ import { rpcHandler } from "./socket/rpcHandler"; import { pingHandler } from "./socket/pingHandler"; import { sessionUpdateHandler } from "./socket/sessionUpdateHandler"; import { machineUpdateHandler } from "./socket/machineUpdateHandler"; +import { artifactUpdateHandler } from "./socket/artifactUpdateHandler"; export function startSocket(app: Fastify, eventRouter: EventRouter) { const io = new Server(app.server, { @@ -140,6 +141,7 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) { sessionUpdateHandler(userId, socket, connection, eventRouter); pingHandler(socket); machineUpdateHandler(userId, socket, eventRouter); + artifactUpdateHandler(userId, socket, eventRouter); // Ready log({ module: 'websocket' }, `User connected: ${userId}`); diff --git a/sources/app/api/socket/artifactUpdateHandler.ts b/sources/app/api/socket/artifactUpdateHandler.ts new file mode 100644 index 0000000..0897353 --- /dev/null +++ b/sources/app/api/socket/artifactUpdateHandler.ts @@ -0,0 +1,407 @@ +import { websocketEventsCounter } from "@/app/monitoring/metrics2"; +import { buildNewArtifactUpdate, buildUpdateArtifactUpdate, buildDeleteArtifactUpdate, EventRouter } from "@/app/events/eventRouter"; +import { db } from "@/storage/db"; +import { allocateUserSeq } from "@/storage/seq"; +import { log } from "@/utils/log"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { Socket } from "socket.io"; +import * as privacyKit from "privacy-kit"; + +export function artifactUpdateHandler(userId: string, socket: Socket, eventRouter: EventRouter) { + // Read artifact with full body + socket.on('artifact-read', async (data: { + artifactId: string; + }, callback: (response: any) => void) => { + try { + websocketEventsCounter.inc({ event_type: 'artifact-read' }); + + const { artifactId } = data; + + // Validate input + if (!artifactId) { + if (callback) { + callback({ result: 'error', message: 'Invalid parameters' }); + } + return; + } + + // Fetch artifact + const artifact = await db.artifact.findFirst({ + where: { + id: artifactId, + accountId: userId + } + }); + + if (!artifact) { + if (callback) { + callback({ result: 'error', message: 'Artifact not found' }); + } + return; + } + + // Return artifact data + callback({ + result: 'success', + artifact: { + id: artifact.id, + header: privacyKit.encodeBase64(artifact.header), + headerVersion: artifact.headerVersion, + body: privacyKit.encodeBase64(artifact.body), + bodyVersion: artifact.bodyVersion, + seq: artifact.seq, + createdAt: artifact.createdAt.getTime(), + updatedAt: artifact.updatedAt.getTime() + } + }); + } catch (error) { + log({ module: 'websocket', level: 'error' }, `Error in artifact-read: ${error}`); + if (callback) { + callback({ result: 'error', message: 'Internal error' }); + } + } + }); + + // Update artifact with optimistic concurrency control + socket.on('artifact-update', async (data: { + artifactId: string; + header?: { + data: string; + expectedVersion: number; + }; + body?: { + data: string; + expectedVersion: number; + }; + }, callback: (response: any) => void) => { + try { + websocketEventsCounter.inc({ event_type: 'artifact-update' }); + + const { artifactId, header, body } = data; + + // Validate input + if (!artifactId) { + if (callback) { + callback({ result: 'error', message: 'Invalid parameters' }); + } + return; + } + + // At least one update must be provided + if (!header && !body) { + if (callback) { + callback({ result: 'error', message: 'No updates provided' }); + } + return; + } + + // Validate header structure if provided + if (header && (typeof header.data !== 'string' || typeof header.expectedVersion !== 'number')) { + if (callback) { + callback({ result: 'error', message: 'Invalid header parameters' }); + } + return; + } + + // Validate body structure if provided + if (body && (typeof body.data !== 'string' || typeof body.expectedVersion !== 'number')) { + if (callback) { + callback({ result: 'error', message: 'Invalid body parameters' }); + } + return; + } + + // Get current artifact + const currentArtifact = await db.artifact.findFirst({ + where: { + id: artifactId, + accountId: userId + } + }); + + if (!currentArtifact) { + if (callback) { + callback({ result: 'error', message: 'Artifact not found' }); + } + return; + } + + // Check for version mismatches + const headerMismatch = header && currentArtifact.headerVersion !== header.expectedVersion; + const bodyMismatch = body && currentArtifact.bodyVersion !== body.expectedVersion; + + if (headerMismatch || bodyMismatch) { + const response: any = { result: 'version-mismatch' }; + + if (headerMismatch) { + response.header = { + currentVersion: currentArtifact.headerVersion, + currentData: privacyKit.encodeBase64(currentArtifact.header) + }; + } + + if (bodyMismatch) { + response.body = { + currentVersion: currentArtifact.bodyVersion, + currentData: privacyKit.encodeBase64(currentArtifact.body) + }; + } + + callback(response); + return; + } + + // Build update data + const updateData: any = { + updatedAt: new Date(), + seq: currentArtifact.seq + 1 + }; + + let headerUpdate: { value: string; version: number } | undefined; + let bodyUpdate: { value: string; version: number } | undefined; + + if (header) { + updateData.header = privacyKit.decodeBase64(header.data); + updateData.headerVersion = header.expectedVersion + 1; + headerUpdate = { + value: header.data, + version: header.expectedVersion + 1 + }; + } + + if (body) { + updateData.body = privacyKit.decodeBase64(body.data); + updateData.bodyVersion = body.expectedVersion + 1; + bodyUpdate = { + value: body.data, + version: body.expectedVersion + 1 + }; + } + + // Perform atomic update with version check + const { count } = await db.artifact.updateMany({ + where: { + id: artifactId, + accountId: userId, + ...(header && { headerVersion: header.expectedVersion }), + ...(body && { bodyVersion: body.expectedVersion }) + }, + data: updateData + }); + + if (count === 0) { + // Re-fetch current version + const current = await db.artifact.findFirst({ + where: { + id: artifactId, + accountId: userId + } + }); + + const response: any = { result: 'version-mismatch' }; + + if (header && current) { + response.header = { + currentVersion: current.headerVersion, + currentData: privacyKit.encodeBase64(current.header) + }; + } + + if (body && current) { + response.body = { + currentVersion: current.bodyVersion, + currentData: privacyKit.encodeBase64(current.body) + }; + } + + callback(response); + return; + } + + // Emit update event + const updSeq = await allocateUserSeq(userId); + const updatePayload = buildUpdateArtifactUpdate(artifactId, updSeq, randomKeyNaked(12), headerUpdate, bodyUpdate); + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + // Send success response + const response: any = { result: 'success' }; + + if (headerUpdate) { + response.header = { + version: headerUpdate.version, + data: header!.data + }; + } + + if (bodyUpdate) { + response.body = { + version: bodyUpdate.version, + data: body!.data + }; + } + + callback(response); + } catch (error) { + log({ module: 'websocket', level: 'error' }, `Error in artifact-update: ${error}`); + if (callback) { + callback({ result: 'error', message: 'Internal error' }); + } + } + }); + + // Create new artifact + socket.on('artifact-create', async (data: { + id: string; + header: string; + body: string; + dataEncryptionKey: string; + }, callback: (response: any) => void) => { + try { + websocketEventsCounter.inc({ event_type: 'artifact-create' }); + + const { id, header, body, dataEncryptionKey } = data; + + // Validate input + if (!id || typeof header !== 'string' || typeof body !== 'string' || typeof dataEncryptionKey !== 'string') { + if (callback) { + callback({ result: 'error', message: 'Invalid parameters' }); + } + return; + } + + // Check if artifact already exists + const existingArtifact = await db.artifact.findUnique({ + where: { id } + }); + + if (existingArtifact) { + // If exists for another account, return error + if (existingArtifact.accountId !== userId) { + if (callback) { + callback({ result: 'error', message: 'Artifact with this ID already exists for another account' }); + } + return; + } + + // If exists for same account, return existing (idempotent) + callback({ + result: 'success', + artifact: { + id: existingArtifact.id, + header: privacyKit.encodeBase64(existingArtifact.header), + headerVersion: existingArtifact.headerVersion, + body: privacyKit.encodeBase64(existingArtifact.body), + bodyVersion: existingArtifact.bodyVersion, + seq: existingArtifact.seq, + createdAt: existingArtifact.createdAt.getTime(), + updatedAt: existingArtifact.updatedAt.getTime() + } + }); + return; + } + + // Create new artifact + const artifact = await db.artifact.create({ + data: { + id, + accountId: userId, + header: privacyKit.decodeBase64(header), + headerVersion: 1, + body: privacyKit.decodeBase64(body), + bodyVersion: 1, + dataEncryptionKey: privacyKit.decodeBase64(dataEncryptionKey), + seq: 0 + } + }); + + // Emit new-artifact event + const updSeq = await allocateUserSeq(userId); + const newArtifactPayload = buildNewArtifactUpdate(artifact, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload: newArtifactPayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + // Return created artifact + callback({ + result: 'success', + artifact: { + id: artifact.id, + header: privacyKit.encodeBase64(artifact.header), + headerVersion: artifact.headerVersion, + body: privacyKit.encodeBase64(artifact.body), + bodyVersion: artifact.bodyVersion, + seq: artifact.seq, + createdAt: artifact.createdAt.getTime(), + updatedAt: artifact.updatedAt.getTime() + } + }); + } catch (error) { + log({ module: 'websocket', level: 'error' }, `Error in artifact-create: ${error}`); + if (callback) { + callback({ result: 'error', message: 'Internal error' }); + } + } + }); + + // Delete artifact + socket.on('artifact-delete', async (data: { + artifactId: string; + }, callback: (response: any) => void) => { + try { + websocketEventsCounter.inc({ event_type: 'artifact-delete' }); + + const { artifactId } = data; + + // Validate input + if (!artifactId) { + if (callback) { + callback({ result: 'error', message: 'Invalid parameters' }); + } + return; + } + + // Check if artifact exists and belongs to user + const artifact = await db.artifact.findFirst({ + where: { + id: artifactId, + accountId: userId + } + }); + + if (!artifact) { + if (callback) { + callback({ result: 'error', message: 'Artifact not found' }); + } + return; + } + + // Delete artifact + await db.artifact.delete({ + where: { id: artifactId } + }); + + // Emit delete-artifact event + const updSeq = await allocateUserSeq(userId); + const deletePayload = buildDeleteArtifactUpdate(artifactId, updSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId, + payload: deletePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + // Send success response + callback({ result: 'success' }); + } catch (error) { + log({ module: 'websocket', level: 'error' }, `Error in artifact-delete: ${error}`); + if (callback) { + callback({ result: 'error', message: 'Internal error' }); + } + } + }); +} \ No newline at end of file diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index f12218b..03676c7 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -105,6 +105,31 @@ export type UpdateEvent = { version: number; }; activeAt?: number; +} | { + type: 'new-artifact'; + artifactId: string; + seq: number; + header: string; + headerVersion: number; + body: string; + bodyVersion: number; + dataEncryptionKey: string | null; + createdAt: number; + updatedAt: number; +} | { + type: 'update-artifact'; + artifactId: string; + header?: { + value: string; + version: number; + }; + body?: { + value: string; + version: number; + }; +} | { + type: 'delete-artifact'; + artifactId: string; }; // === EPHEMERAL EVENT TYPES (Transient) === @@ -447,4 +472,60 @@ export function buildMachineStatusEphemeral(machineId: string, online: boolean): online, timestamp: Date.now() }; +} + +export function buildNewArtifactUpdate(artifact: { + id: string; + seq: number; + header: Uint8Array; + headerVersion: number; + body: Uint8Array; + bodyVersion: number; + dataEncryptionKey: Uint8Array; + createdAt: Date; + updatedAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'new-artifact', + artifactId: artifact.id, + seq: artifact.seq, + header: Buffer.from(artifact.header).toString('base64'), + headerVersion: artifact.headerVersion, + body: Buffer.from(artifact.body).toString('base64'), + bodyVersion: artifact.bodyVersion, + dataEncryptionKey: Buffer.from(artifact.dataEncryptionKey).toString('base64'), + createdAt: artifact.createdAt.getTime(), + updatedAt: artifact.updatedAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildUpdateArtifactUpdate(artifactId: string, updateSeq: number, updateId: string, header?: { value: string; version: number }, body?: { value: string; version: number }): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'update-artifact', + artifactId, + header, + body + }, + createdAt: Date.now() + }; +} + +export function buildDeleteArtifactUpdate(artifactId: string, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'delete-artifact', + artifactId + }, + createdAt: Date.now() + }; } \ No newline at end of file diff --git a/sources/types.ts b/sources/types.ts index 89e4a16..9cd0a48 100644 --- a/sources/types.ts +++ b/sources/types.ts @@ -11,4 +11,19 @@ export type AccountProfile = { version: number; } | null; connectedServices: string[]; +} + +export type ArtifactInfo = { + id: string; + header: string; + headerVersion: number; + dataEncryptionKey: string; + seq: number; + createdAt: number; + updatedAt: number; +} + +export type Artifact = ArtifactInfo & { + body: string; + bodyVersion: number; } \ No newline at end of file