This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
// 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
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user