314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
// 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
|
|
}
|
|
},
|
|
})
|