683fc62129
- schema: userDotfiles (one encrypted row per file, HOME-relative path) and
userEnvironment (home username + optional public dotfiles repo + setup command)
- userDotfiles.ts: list/remove/removeDirectory/rename + internal upsert/getRaw
- userDotfilesNode.ts ('use node'): putFile/importFiles/getFileContent (encrypt
/decrypt via secretCrypto) + getEnvironmentForJob (worker-token, returns the
owner's decrypted dotfiles + repo/setup config)
- userEnvironment.ts: getMine/updateMine + getRawEnvironmentForJobInternal
- model.ts: deriveHomeUsername + normalizeDotfilePath helpers
137 lines
4.0 KiB
TypeScript
137 lines
4.0 KiB
TypeScript
'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<Id<'users'>> => {
|
|
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<WorkerEnvironment | null> => {
|
|
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,
|
|
})),
|
|
};
|
|
},
|
|
});
|