import { env } from "@code/env/convex"; import { makeFunctionReference } from "convex/server"; import { ConvexError } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { httpAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; // never call Convex mutations directly; they POST to these HTTP endpoints and // we run the validated mutations server-side. const appendToolEventRef = makeFunctionReference< "mutation", { actorTool: string; artifactIds?: Id<"artifacts">[]; correlationId: string; idempotencyKey: string; organizationId: Id<"organizations">; payload: unknown; projectId?: Id<"projects">; replyToEventId?: Id<"events">; scopeKind: "global" | "work"; threadId?: Id<"threads">; type: string; visibility: "timeline" | "compact" | "internal"; workId?: Id<"works">; }, Id<"events"> >("events:appendToolEvent"); const publishRevisionRef = makeFunctionReference< "mutation", { card: unknown; content: unknown; createdByEventId: Id<"events">; kind: string; logicalKey: string; organizationId: Id<"organizations">; projectId?: Id<"projects">; status: string; storageId?: Id<"_storage">; summary: string; threadId?: Id<"threads">; title: string; workId?: Id<"works">; }, { _id: Id<"artifacts">; _creationTime: number } >("artifacts:publishRevision"); const getAgentForProjectRef = makeFunctionReference< "query", { projectId: Id<"projects"> }, { _id: Id<"conversationAgents">; id: string; organizationId: Id<"organizations">; projectId: Id<"projects">; } | null >("conversationAgents:getForProject"); /** * Validate the `Authorization: Bearer ` header. Returns the * token on success; throws a ConvexError (mapped to 401 by the httpAction). */ const requireServiceToken = (request: Request): string => { if (!env.FLUE_DB_TOKEN) { throw new ConvexError("FLUE_DB_TOKEN is not configured"); } const header = request.headers.get("authorization") ?? ""; if (!header.startsWith("Bearer ")) { throw new ConvexError("Missing bearer token"); } const token = header.slice("Bearer ".length).trim(); if (token.length === 0 || token !== env.FLUE_DB_TOKEN) { throw new ConvexError("Invalid service token"); } return token; }; interface BoundaryContext { agentId: string; organizationId: Id<"organizations">; projectId: Id<"projects">; } /** Shape returned by the conversationAgents:getForProject internal query. */ interface AgentRow { _id: Id<"conversationAgents">; id: string; organizationId: Id<"organizations">; projectId: Id<"projects">; } /** * Enforce the agent -> project -> organization boundary. The caller asserts an * `agentId` of the form `conversation:::v1`; we verify the * conversationAgents row exists for that project and belongs to the claimed * organization. This prevents a project-A agent from writing into project-B. */ const resolveBoundary = async ( ctx: ActionCtx, body: { agentId?: unknown; organizationId?: unknown; projectId?: unknown } ): Promise => { if (typeof body.agentId !== "string" || body.agentId.length === 0) { throw new ConvexError("agentId is required"); } if (typeof body.projectId !== "string") { throw new ConvexError("projectId is required"); } if (typeof body.organizationId !== "string") { throw new ConvexError("organizationId is required"); } const projectId = body.projectId as Id<"projects">; const organizationId = body.organizationId as Id<"organizations">; const agent = (await ctx.runQuery(getAgentForProjectRef, { projectId, })) as AgentRow | null; if ( !agent || agent.id !== body.agentId || agent.organizationId !== organizationId ) { throw new ConvexError("Agent is not bound to this project/organization"); } return { agentId: body.agentId, organizationId, projectId }; }; const json = (status: number, payload: unknown): Response => Response.json(payload, { status }); const asString = (value: unknown, field: string): string => { if (typeof value !== "string" || value.length === 0) { throw new ConvexError(`${field} is required`); } return value; }; const asOptionalId = (value: unknown): string | undefined => { if (typeof value !== "string" || value.length === 0) { return undefined; } return value; }; /** * POST /api/agents/events/message * * Service-authenticated endpoint for a project conversation agent to append a * timeline event (an assistant message, acknowledgement, etc.). Enforces the * agent->project->organization boundary and exact-once semantics via the * supplied idempotency key. * * Body: { * agentId, organizationId, projectId, * text, type, visibility, idempotencyKey, correlationId, * replyToEventId?, workId?, threadId? * } */ export const postAgentMessage = httpAction(async (ctx, request) => { try { requireServiceToken(request); const body = (await request.json()) as Record; const boundary = await resolveBoundary(ctx, body); const text = asString(body.text, "text"); const type = asString(body.type, "type"); const visibility = asString(body.visibility, "visibility"); const idempotencyKey = asString(body.idempotencyKey, "idempotencyKey"); const correlationId = asString(body.correlationId, "correlationId"); if (!["timeline", "compact", "internal"].includes(visibility)) { return json(400, { error: "Invalid visibility" }); } const eventId = (await ctx.runMutation(appendToolEventRef, { actorTool: boundary.agentId, artifactIds: undefined, correlationId, idempotencyKey, organizationId: boundary.organizationId, payload: { text }, projectId: boundary.projectId, replyToEventId: asOptionalId(body.replyToEventId) as | Id<"events"> | undefined, scopeKind: "global", threadId: asOptionalId(body.threadId) as Id<"threads"> | undefined, type, visibility: visibility as "timeline" | "compact" | "internal", workId: asOptionalId(body.workId) as Id<"works"> | undefined, })) as Id<"events">; return json(200, { eventId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const status = message.startsWith("Unauthorized") ? 401 : 400; return json(status, { error: message }); } }); /** * POST /api/agents/events/artifact * * Service-authenticated endpoint for a project conversation agent to publish a * typed artifact revision (with an optional Convex Storage id) and emit the * `artifact.published` event referencing it. Enforces the boundary and is * idempotent through the artifact's logicalKey+version and the event key. * * Body: { * agentId, organizationId, projectId, * createdByEventId, kind, logicalKey, title, summary, status, * card, content, storageId?, idempotencyKey, correlationId, * workId?, threadId? * } */ export const publishAgentArtifact = httpAction(async (ctx, request) => { try { requireServiceToken(request); const body = (await request.json()) as Record; const boundary = await resolveBoundary(ctx, body); const createdByEventId = asString( body.createdByEventId, "createdByEventId" ) as Id<"events">; const kind = asString(body.kind, "kind"); const logicalKey = asString(body.logicalKey, "logicalKey"); const title = asString(body.title, "title"); const summary = asString(body.summary, "summary"); const status = asString(body.status, "status"); const idempotencyKey = asString(body.idempotencyKey, "idempotencyKey"); const correlationId = asString(body.correlationId, "correlationId"); const card = body.card ?? {}; const content = body.content ?? {}; const artifact = (await ctx.runMutation(publishRevisionRef, { card, content, createdByEventId, kind, logicalKey, organizationId: boundary.organizationId, projectId: boundary.projectId, status, storageId: asOptionalId(body.storageId) as Id<"_storage"> | undefined, summary, threadId: asOptionalId(body.threadId) as Id<"threads"> | undefined, title, workId: asOptionalId(body.workId) as Id<"works"> | undefined, })) as { _id: Id<"artifacts"> }; // Emit the artifact.published timeline event (exact-once). const eventId = (await ctx.runMutation(appendToolEventRef, { actorTool: boundary.agentId, artifactIds: [artifact._id], correlationId, idempotencyKey: `${idempotencyKey}:event`, organizationId: boundary.organizationId, payload: { artifactId: artifact._id, title }, projectId: boundary.projectId, scopeKind: "global", threadId: asOptionalId(body.threadId) as Id<"threads"> | undefined, type: "artifact.published", visibility: "timeline", workId: asOptionalId(body.workId) as Id<"works"> | undefined, })) as Id<"events">; return json(200, { artifactId: artifact._id, eventId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const status = message.startsWith("Unauthorized") ? 401 : 400; return json(status, { error: message }); } }); /** * POST /api/agents/storage/generate-upload-url * * Service-authenticated endpoint for an agent to obtain a one-time Convex * Storage upload URL for a binary artifact (e.g. rendered HTML). The agent * uploads the bytes to the returned URL and then references the resulting * storageId in {@link publishAgentArtifact}. * * Body: { agentId, organizationId, projectId } */ export const generateAgentUploadUrl = httpAction(async (ctx, request) => { try { requireServiceToken(request); const body = (await request.json()) as Record; await resolveBoundary(ctx, body); const url = await ctx.storage.generateUploadUrl(); return json(200, { url }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const status = message.startsWith("Unauthorized") ? 401 : 400; return json(status, { error: message }); } });