'use node'; import { getAuthUserId } from '@convex-dev/auth/server'; import { ConvexError, v } from 'convex/values'; import type { Id } from './_generated/dataModel'; import type { ActionCtx } from './_generated/server'; import { internal } from './_generated/api'; import { action } from './_generated/server'; import { normalizeDotfilePath } from './model'; import { decryptSecret, encryptSecret } from './secretCrypto'; const MAX_FILE_BYTES = 512 * 1024; // 512 KB per dotfile const getRequiredUserId = async (ctx: ActionCtx): Promise> => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); return userId; }; const requireWorkerToken = (workerToken: string) => { const expected = process.env.SPOON_WORKER_TOKEN; if (!expected) throw new ConvexError('Worker token is not configured.'); if (workerToken !== expected) throw new ConvexError('Invalid worker token.'); }; const putOne = async ( ctx: ActionCtx, ownerId: Id<'users'>, rawPath: string, content: string, isExecutable?: boolean, ) => { const path = normalizeDotfilePath(rawPath); const size = Buffer.byteLength(content, 'utf8'); if (size > MAX_FILE_BYTES) { throw new ConvexError(`${path} is too large (max 512 KB).`); } await ctx.runMutation(internal.userDotfiles.upsertFileInternal, { ownerId, path, encryptedContent: encryptSecret(content), size, isExecutable, }); return path; }; /** Create/update a single dotfile (used by the in-app editor and "new file"). */ export const putFile = action({ args: { path: v.string(), content: v.string(), isExecutable: v.optional(v.boolean()), }, handler: async (ctx, args): Promise<{ path: string }> => { const ownerId = await getRequiredUserId(ctx); const path = await putOne( ctx, ownerId, args.path, args.content, args.isExecutable, ); return { path }; }, }); /** Bulk import (drag-and-drop folder/files). */ export const importFiles = action({ args: { files: v.array( v.object({ path: v.string(), content: v.string(), isExecutable: v.optional(v.boolean()), }), ), }, handler: async (ctx, args): Promise<{ imported: number }> => { const ownerId = await getRequiredUserId(ctx); for (const file of args.files) { await putOne(ctx, ownerId, file.path, file.content, file.isExecutable); } return { imported: args.files.length }; }, }); /** Decrypts one file's content for the editor (owner only). */ export const getFileContent = action({ args: { fileId: v.id('userDotfiles') }, handler: async (ctx, { fileId }): Promise<{ content: string }> => { const ownerId = await getRequiredUserId(ctx); const file = await ctx.runQuery(internal.userDotfiles.getRawFileInternal, { fileId, }); if (file?.ownerId !== ownerId) { throw new ConvexError('Dotfile not found.'); } return { content: decryptSecret(file.encryptedContent) }; }, }); type WorkerEnvironment = { username: string; enabled: boolean; dotfilesRepoUrl?: string; dotfilesRepoRef?: string; setupCommand?: string; files: { path: string; content: string; isExecutable: boolean }[]; }; /** Worker-facing: the job owner's full environment with dotfiles decrypted. */ export const getEnvironmentForJob = action({ args: { workerToken: v.string(), jobId: v.id('agentJobs') }, handler: async (ctx, args): Promise => { requireWorkerToken(args.workerToken); const raw = await ctx.runQuery( internal.userEnvironment.getRawEnvironmentForJobInternal, { jobId: args.jobId }, ); if (!raw) return null; return { username: raw.username, enabled: raw.enabled, dotfilesRepoUrl: raw.dotfilesRepoUrl, dotfilesRepoRef: raw.dotfilesRepoRef, setupCommand: raw.setupCommand, files: raw.files.map((f) => ({ path: f.path, content: decryptSecret(f.encryptedContent), isExecutable: f.isExecutable, })), }; }, });