// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // Object-store effects are Node actions; each pairs with internal mutations in files.ts. import { type Actor, AppError, toProblem, unauthenticated, validationFailed } from "@conusai-kit/core" import { systemActor } from "@conusai-kit/core/worker" import { DEFAULT_FILES_POLICY, digestStream, platformObjectStore, StreamTooLarge } from "@conusai-kit/files" // The object-store entry alone: the package root also exports Node-only adapters (the audit // client), which a Convex action outside "use node" cannot bundle. import { createObjectStore } from "@conusai-kit/platform/object-store" import { makeFunctionReference, type UserIdentity } from "convex/server" import { ConvexError, type Value, v } from "convex/values" import { action, internalAction } from "./_generated/server" import { CONVEX_BFF_ISSUER } from "./lib/runtime" const requiredEnv = (name: string): string => { const value = process.env[name]?.trim() if (!value) throw new Error(`Convex deployment is missing ${name}`) return value } const objectStore = () => { const apiUrl = requiredEnv("CONUSAI_API_URL") let parsed: URL try { parsed = new URL(apiUrl) } catch { throw new Error("Convex deployment has an invalid CONUSAI_API_URL") } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Convex deployment has an invalid CONUSAI_API_URL") } return platformObjectStore({ blob: createObjectStore({ CONUSAI_API_URL: apiUrl, CONUSAI_API_TOKEN: requiredEnv("CONUSAI_API_TOKEN"), CONUSAI_PROJECT_ID: requiredEnv("CONUSAI_PROJECT_ID"), }), }) } const trustedIdentity = (identity: UserIdentity): Actor => { if (identity.issuer !== CONVEX_BFF_ISSUER || !identity.subject) { throwProblem(unauthenticated("file actions accept only an authenticated app BFF identity")) } return { userId: identity.subject } } function throwProblem(error: unknown): never { if (error instanceof AppError) throw new ConvexError(toProblem(error) as unknown as Value) throw error } const identityForMutation = (identity: UserIdentity) => ({ issuer: identity.issuer, subject: identity.subject, tokenIdentifier: identity.tokenIdentifier, ...("conusaiOrgId" in identity && typeof identity.conusaiOrgId === "string" ? { conusaiOrgId: identity.conusaiOrgId } : {}), }) const identityValidator = v.object({ issuer: v.string(), subject: v.string(), tokenIdentifier: v.string(), email: v.optional(v.string()), name: v.optional(v.string()), conusaiOrgId: v.optional(v.string()), }) const mutationRef = (name: string) => makeFunctionReference<"mutation">(name) const actionRef = (name: string) => makeFunctionReference<"action">(name) /** Called by web readiness to prove that the deployed backend can execute its file action module. */ export const readiness = action({ args: {}, handler: async () => { objectStore() requiredEnv("CONUSAI_API_TOKEN") requiredEnv("CONUSAI_PROJECT_ID") return { ready: true } }, }) export const begin = action({ args: { input: v.any() }, handler: async (ctx, { input }) => { const identity = await ctx.auth.getUserIdentity() if (!identity) throwProblem(unauthenticated()) trustedIdentity(identity) const safeIdentity = identityForMutation(identity) const started = await ctx.runMutation(mutationRef("files:beginIntent"), { identity: safeIdentity, input, } as never) const plan = await objectStore() .beginUpload( { level: "project", actor: trustedIdentity(identity), ...started.scope }, { key: started.intent.storageKey, contentType: started.intent.contentType, size: started.intent.declaredSize }, ) .catch((error: unknown) => { if (error instanceof AppError) throwProblem(error) throw new Error("the platform could not open a file upload session") }) const pending = await ctx.runMutation(mutationRef("files:saveUploadSession"), { identity: safeIdentity, input, intent: started.intent, plan, } as never) return { fileId: pending.fileId, parts: pending.parts, expiresAt: pending.expiresAt } }, }) export const complete = action({ args: { input: v.any() }, handler: async (ctx, { input }) => { const identity = await ctx.auth.getUserIdentity() if (!identity) throwProblem(unauthenticated()) trustedIdentity(identity) const safeIdentity = identityForMutation(identity) const info = await ctx.runMutation(mutationRef("files:completeInfo"), { identity: safeIdentity, input, } as never) if (info.alreadyStored) return info.result const scope = { level: "project" as const, actor: trustedIdentity(identity), ...info.scope } let stored: { size: number; sha256: string } try { stored = await objectStore().completeUpload(scope, { key: info.storageKey, uploadId: info.uploadId, parts: input.parts, }) } catch (error) { // A lost response after Blob completed is settled by reading the durable object. Repeating // finalize is safe, and a missing object preserves the original failure. const existing = await objectStore() .get(scope, info.storageKey) .catch(() => null) if (!existing) throwProblem(error) const digest = await digestStream(existing.body, DEFAULT_FILES_POLICY.maxBytes) stored = { size: digest.size, sha256: digest.sha256 } } if (stored.size !== info.declaredSize) { throwProblem( validationFailed([{ path: "size", message: "the stored file does not match the size declared for it" }]), ) } return ctx.runMutation(mutationRef("files:finalizeUpload"), { identity: safeIdentity, input, fileId: info.fileId, stored, } as never) }, }) export const remove = action({ args: { input: v.any() }, handler: async (ctx, { input }) => { const identity = await ctx.auth.getUserIdentity() if (!identity) throwProblem(unauthenticated()) trustedIdentity(identity) const safeIdentity = identityForMutation(identity) const marked = await ctx.runMutation(mutationRef("files:markDelete"), { identity: safeIdentity, input, } as never) try { const scope = { level: "project" as const, actor: trustedIdentity(identity), ...marked.scope } await objectStore().delete(scope, marked.storageKey) await ctx.runMutation(mutationRef("files:finalizeDelete"), { identity: safeIdentity, input, fileId: marked.fileId, } as never) return null } catch (error) { await ctx.scheduler.runAfter(1_000, actionRef("filesActions:retryDelete"), { identity: safeIdentity, input, attempt: 0, }) throwProblem(error) } }, }) export const retryDelete = internalAction({ args: { identity: identityValidator, input: v.any(), attempt: v.number() }, handler: async (ctx, { identity, input, attempt }) => { try { const marked = await ctx.runMutation(mutationRef("files:markDelete"), { identity, input } as never) const scope = { level: "project" as const, actor: trustedIdentity(identity), ...marked.scope } await objectStore().delete(scope, marked.storageKey) await ctx.runMutation(mutationRef("files:finalizeDelete"), { identity, input, fileId: marked.fileId, } as never) } catch (error) { if (attempt < 8) { await ctx.scheduler.runAfter(Math.min(300_000, 1_000 * 2 ** attempt), actionRef("filesActions:retryDelete"), { identity, input, attempt: attempt + 1, }) } throw error } }, }) export const cleanupDelete = internalAction({ args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string(), attempt: v.number(), }, handler: async (ctx, args) => { try { const { attempt, ...ids } = args const info = await ctx.runMutation(mutationRef("files:systemDeleteInfo"), ids as never) if (!info) return const scope = { level: "project" as const, actor: systemActor("files.convex-recovery"), orgId: args.orgId, workspaceId: args.workspaceId, projectId: args.projectId, } await objectStore().delete(scope, info.storageKey) await ctx.runMutation(mutationRef("files:systemFinalizeDelete"), ids as never) } catch (error) { if (args.attempt < 8) { await ctx.scheduler.runAfter( Math.min(300_000, 1_000 * 2 ** args.attempt), actionRef("filesActions:cleanupDelete"), { ...args, attempt: args.attempt + 1 }, ) } throw error } }, }) /** Reconcile an upload whose request may have crashed at any boundary after its row was created. */ export const recoverUpload = internalAction({ args: { orgId: v.string(), workspaceId: v.string(), projectId: v.string(), fileId: v.string(), attempt: v.number(), }, handler: async (ctx, args) => { const { attempt, ...ids } = args try { const info = await ctx.runMutation(mutationRef("files:systemPendingInfo"), ids as never) if (!info) return const scope = { level: "project" as const, actor: systemActor("files.convex-recovery"), orgId: args.orgId, workspaceId: args.workspaceId, projectId: args.projectId, } const stored = await objectStore().get(scope, info.storageKey) if (!stored || info.declaredSize === null) { await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never) await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), { ...ids, attempt: 0, }) return } let digest: { size: number; sha256: string } try { digest = await digestStream(stored.body, DEFAULT_FILES_POLICY.maxBytes) } catch (error) { if (!(error instanceof StreamTooLarge)) throw error await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never) await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), { ...ids, attempt: 0, }) return } if (digest.size !== info.declaredSize) { await ctx.runMutation(mutationRef("files:systemAbandonUpload"), ids as never) await ctx.scheduler.runAfter(0, actionRef("filesActions:cleanupDelete"), { ...ids, attempt: 0, }) return } await ctx.runMutation(mutationRef("files:systemFinalizeUpload"), { ...ids, stored: digest, } as never) } catch (error) { if (attempt < 8) { await ctx.scheduler.runAfter(Math.min(300_000, 1_000 * 2 ** attempt), actionRef("filesActions:recoverUpload"), { ...args, attempt: attempt + 1, }) } throw error } }, })