wip: working on artifacts
This commit is contained in:
parent
e961407993
commit
86bf8bf03c
24
prisma/migrations/20250917052000_add_artefacts/migration.sql
Normal file
24
prisma/migrations/20250917052000_add_artefacts/migration.sql
Normal file
@ -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;
|
@ -44,6 +44,7 @@ model Account {
|
|||||||
Machine Machine[]
|
Machine Machine[]
|
||||||
UploadedFile UploadedFile[]
|
UploadedFile UploadedFile[]
|
||||||
ServiceAccountToken ServiceAccountToken[]
|
ServiceAccountToken ServiceAccountToken[]
|
||||||
|
Artifact Artifact[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TerminalAuthRequest {
|
model TerminalAuthRequest {
|
||||||
@ -241,3 +242,24 @@ model ServiceAccountToken {
|
|||||||
@@unique([accountId, vendor])
|
@@unique([accountId, vendor])
|
||||||
@@index([accountId])
|
@@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)])
|
||||||
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { machinesRoutes } from "./routes/machinesRoutes";
|
|||||||
import { devRoutes } from "./routes/devRoutes";
|
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 { 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";
|
||||||
@ -54,6 +55,7 @@ export async function startApi(eventRouter: EventRouter) {
|
|||||||
accountRoutes(typed, eventRouter);
|
accountRoutes(typed, eventRouter);
|
||||||
connectRoutes(typed, eventRouter);
|
connectRoutes(typed, eventRouter);
|
||||||
machinesRoutes(typed, eventRouter);
|
machinesRoutes(typed, eventRouter);
|
||||||
|
artifactsRoutes(typed, eventRouter);
|
||||||
devRoutes(typed);
|
devRoutes(typed);
|
||||||
versionRoutes(typed);
|
versionRoutes(typed);
|
||||||
voiceRoutes(typed);
|
voiceRoutes(typed);
|
||||||
|
414
sources/app/api/routes/artifactsRoutes.ts
Normal file
414
sources/app/api/routes/artifactsRoutes.ts
Normal file
@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -10,6 +10,7 @@ import { rpcHandler } from "./socket/rpcHandler";
|
|||||||
import { pingHandler } from "./socket/pingHandler";
|
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";
|
||||||
|
|
||||||
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, {
|
||||||
@ -140,6 +141,7 @@ export function startSocket(app: Fastify, eventRouter: EventRouter) {
|
|||||||
sessionUpdateHandler(userId, socket, connection, eventRouter);
|
sessionUpdateHandler(userId, socket, connection, eventRouter);
|
||||||
pingHandler(socket);
|
pingHandler(socket);
|
||||||
machineUpdateHandler(userId, socket, eventRouter);
|
machineUpdateHandler(userId, socket, eventRouter);
|
||||||
|
artifactUpdateHandler(userId, socket, eventRouter);
|
||||||
|
|
||||||
// Ready
|
// Ready
|
||||||
log({ module: 'websocket' }, `User connected: ${userId}`);
|
log({ module: 'websocket' }, `User connected: ${userId}`);
|
||||||
|
407
sources/app/api/socket/artifactUpdateHandler.ts
Normal file
407
sources/app/api/socket/artifactUpdateHandler.ts
Normal file
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -105,6 +105,31 @@ export type UpdateEvent = {
|
|||||||
version: number;
|
version: number;
|
||||||
};
|
};
|
||||||
activeAt?: 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) ===
|
// === EPHEMERAL EVENT TYPES (Transient) ===
|
||||||
@ -447,4 +472,60 @@ export function buildMachineStatusEphemeral(machineId: string, online: boolean):
|
|||||||
online,
|
online,
|
||||||
timestamp: Date.now()
|
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()
|
||||||
|
};
|
||||||
}
|
}
|
@ -11,4 +11,19 @@ export type AccountProfile = {
|
|||||||
version: number;
|
version: number;
|
||||||
} | null;
|
} | null;
|
||||||
connectedServices: string[];
|
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;
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user