From 683fc62129c364d22310f989c3fac840aa0a08a2 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 09:38:43 -0400 Subject: [PATCH] Convex: per-user dotfiles + environment storage (encrypted) - 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 --- packages/backend/convex/model.ts | 25 ++++ packages/backend/convex/schema.ts | 24 ++++ packages/backend/convex/userDotfiles.ts | 121 +++++++++++++++++ packages/backend/convex/userDotfilesNode.ts | 136 ++++++++++++++++++++ packages/backend/convex/userEnvironment.ts | 96 ++++++++++++++ 5 files changed, 402 insertions(+) create mode 100644 packages/backend/convex/userDotfiles.ts create mode 100644 packages/backend/convex/userDotfilesNode.ts create mode 100644 packages/backend/convex/userEnvironment.ts diff --git a/packages/backend/convex/model.ts b/packages/backend/convex/model.ts index 905e283..6f7486f 100644 --- a/packages/backend/convex/model.ts +++ b/packages/backend/convex/model.ts @@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => { if (!trimmed) return undefined; return trimmed; }; + +// Linux username for the per-user container home (/home/). Derived +// from the first token of the profile name, sanitized; falls back to "user". +export const deriveHomeUsername = (name?: string): string => { + const first = (name ?? '').trim().split(/\s+/)[0] ?? ''; + const sanitized = first.toLowerCase().replace(/[^a-z0-9_-]/g, ''); + return sanitized || 'user'; +}; + +// Normalizes a dotfile path to a safe HOME-relative path (no leading slash, no +// "..", no empty segments). Throws on anything that would escape HOME. +export const normalizeDotfilePath = (rawPath: string): string => { + const cleaned = rawPath + .trim() + .replace(/^\.\/+/, '') + .replace(/^\/+/, ''); + const segments = cleaned.split('/').filter((s) => s.length > 0); + if (segments.length === 0) { + throw new ConvexError('A dotfile path is required.'); + } + if (segments.some((s) => s === '..' || s === '.')) { + throw new ConvexError(`Invalid dotfile path: ${rawPath}`); + } + return segments.join('/'); +}; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 02a11eb..8d4cabb 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -348,6 +348,30 @@ const applicationTables = { }) .index('by_user', ['userId']) .index('by_user_provider', ['userId', 'provider']), + // Per-user dotfiles: one row per file, materialized into the workspace + // container's HOME. Content is encrypted at rest (reuses secretCrypto). + // `path` is relative to HOME, e.g. ".bashrc" or ".config/nvim/init.lua". + userDotfiles: defineTable({ + ownerId: v.id('users'), + path: v.string(), + encryptedContent: v.string(), + size: v.number(), + isExecutable: v.optional(v.boolean()), + updatedAt: v.number(), + }) + .index('by_owner', ['ownerId']) + .index('by_owner_path', ['ownerId', 'path']), + // Per-user environment config: the persistent home username + an optional + // public dotfiles repo and setup command run in the container. + userEnvironment: defineTable({ + ownerId: v.id('users'), + enabled: v.boolean(), + homeUsername: v.optional(v.string()), + dotfilesRepoUrl: v.optional(v.string()), + dotfilesRepoRef: v.optional(v.string()), + setupCommand: v.optional(v.string()), + updatedAt: v.number(), + }).index('by_owner', ['ownerId']), aiProviderProfiles: defineTable({ ownerId: v.id('users'), name: v.string(), diff --git a/packages/backend/convex/userDotfiles.ts b/packages/backend/convex/userDotfiles.ts new file mode 100644 index 0000000..b676fa8 --- /dev/null +++ b/packages/backend/convex/userDotfiles.ts @@ -0,0 +1,121 @@ +import { ConvexError, v } from 'convex/values'; + +import type { Doc } from './_generated/dataModel'; +import { + internalMutation, + internalQuery, + mutation, + query, +} from './_generated/server'; +import { getRequiredUserId, normalizeDotfilePath } from './model'; + +const fileMeta = (file: Doc<'userDotfiles'>) => ({ + _id: file._id, + path: file.path, + size: file.size, + isExecutable: file.isExecutable ?? false, + updatedAt: file.updatedAt, +}); + +/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */ +export const listMine = query({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const files = await ctx.db + .query('userDotfiles') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .collect(); + return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path)); + }, +}); + +export const remove = mutation({ + args: { fileId: v.id('userDotfiles') }, + handler: async (ctx, { fileId }) => { + const ownerId = await getRequiredUserId(ctx); + const file = await ctx.db.get(fileId); + if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.'); + await ctx.db.delete(fileId); + return { success: true }; + }, +}); + +/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */ +export const removeDirectory = mutation({ + args: { prefix: v.string() }, + handler: async (ctx, { prefix }) => { + const ownerId = await getRequiredUserId(ctx); + const normalized = normalizeDotfilePath(prefix); + const files = await ctx.db + .query('userDotfiles') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .collect(); + const matches = files.filter( + (f) => f.path === normalized || f.path.startsWith(`${normalized}/`), + ); + await Promise.all(matches.map((f) => ctx.db.delete(f._id))); + return { removed: matches.length }; + }, +}); + +export const rename = mutation({ + args: { fileId: v.id('userDotfiles'), path: v.string() }, + handler: async (ctx, { fileId, path }) => { + const ownerId = await getRequiredUserId(ctx); + const file = await ctx.db.get(fileId); + if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.'); + const normalized = normalizeDotfilePath(path); + const clash = await ctx.db + .query('userDotfiles') + .withIndex('by_owner_path', (q) => + q.eq('ownerId', ownerId).eq('path', normalized), + ) + .unique(); + if (clash && clash._id !== fileId) { + throw new ConvexError(`A dotfile already exists at ${normalized}.`); + } + await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() }); + return { success: true }; + }, +}); + +// Read by the decrypting Node action (userDotfilesNode.getFileContent). +export const getRawFileInternal = internalQuery({ + args: { fileId: v.id('userDotfiles') }, + handler: async (ctx, { fileId }) => await ctx.db.get(fileId), +}); + +// Called by the encrypting Node action (userDotfilesNode). Upserts one file by +// (owner, path). +export const upsertFileInternal = internalMutation({ + args: { + ownerId: v.id('users'), + path: v.string(), + encryptedContent: v.string(), + size: v.number(), + isExecutable: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query('userDotfiles') + .withIndex('by_owner_path', (q) => + q.eq('ownerId', args.ownerId).eq('path', args.path), + ) + .unique(); + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { + encryptedContent: args.encryptedContent, + size: args.size, + isExecutable: args.isExecutable, + updatedAt: now, + }); + return existing._id; + } + return await ctx.db.insert('userDotfiles', { + ...args, + updatedAt: now, + }); + }, +}); diff --git a/packages/backend/convex/userDotfilesNode.ts b/packages/backend/convex/userDotfilesNode.ts new file mode 100644 index 0000000..d87f218 --- /dev/null +++ b/packages/backend/convex/userDotfilesNode.ts @@ -0,0 +1,136 @@ +'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, + })), + }; + }, +}); diff --git a/packages/backend/convex/userEnvironment.ts b/packages/backend/convex/userEnvironment.ts new file mode 100644 index 0000000..3c06349 --- /dev/null +++ b/packages/backend/convex/userEnvironment.ts @@ -0,0 +1,96 @@ +import { ConvexError, v } from 'convex/values'; + +import type { Id } from './_generated/dataModel'; +import type { QueryCtx } from './_generated/server'; +import { internalQuery, mutation, query } from './_generated/server'; +import { deriveHomeUsername, getRequiredUserId, optionalText } from './model'; + +const loadSettings = async (ctx: QueryCtx, ownerId: Id<'users'>) => + await ctx.db + .query('userEnvironment') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .unique(); + +/** Current user's environment settings + the resolved home username/first name. */ +export const getMine = query({ + args: {}, + handler: async (ctx) => { + const ownerId = await getRequiredUserId(ctx); + const [user, settings] = await Promise.all([ + ctx.db.get(ownerId), + loadSettings(ctx, ownerId), + ]); + const firstName = (user?.name ?? '').trim().split(/\s+/)[0] || 'you'; + const username = settings?.homeUsername ?? deriveHomeUsername(user?.name); + return { + enabled: settings?.enabled ?? true, + username, + firstName, + dotfilesRepoUrl: settings?.dotfilesRepoUrl, + dotfilesRepoRef: settings?.dotfilesRepoRef, + setupCommand: settings?.setupCommand, + }; + }, +}); + +export const updateMine = mutation({ + args: { + enabled: v.optional(v.boolean()), + dotfilesRepoUrl: v.optional(v.string()), + dotfilesRepoRef: v.optional(v.string()), + setupCommand: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const ownerId = await getRequiredUserId(ctx); + const repoUrl = optionalText(args.dotfilesRepoUrl); + if (repoUrl && !/^https?:\/\//.test(repoUrl)) { + throw new ConvexError('Dotfiles repo must be a public http(s) URL.'); + } + const existing = await loadSettings(ctx, ownerId); + const patch = { + enabled: args.enabled ?? existing?.enabled ?? true, + dotfilesRepoUrl: repoUrl, + dotfilesRepoRef: optionalText(args.dotfilesRepoRef), + setupCommand: optionalText(args.setupCommand), + updatedAt: Date.now(), + }; + if (existing) { + await ctx.db.patch(existing._id, patch); + return { success: true }; + } + await ctx.db.insert('userEnvironment', { ownerId, ...patch }); + return { success: true }; + }, +}); + +// Worker-facing: everything needed to materialize a job's owner's environment. +// Content stays encrypted here; the Node action decrypts it. Resolves the owner +// from the job. +export const getRawEnvironmentForJobInternal = internalQuery({ + args: { jobId: v.id('agentJobs') }, + handler: async (ctx, { jobId }) => { + const job = await ctx.db.get(jobId); + if (!job) return null; + const ownerId = job.ownerId; + const [user, settings, files] = await Promise.all([ + ctx.db.get(ownerId), + loadSettings(ctx, ownerId), + ctx.db + .query('userDotfiles') + .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) + .collect(), + ]); + return { + username: settings?.homeUsername ?? deriveHomeUsername(user?.name), + enabled: settings?.enabled ?? true, + dotfilesRepoUrl: settings?.dotfilesRepoUrl, + dotfilesRepoRef: settings?.dotfilesRepoRef, + setupCommand: settings?.setupCommand, + files: files.map((f) => ({ + path: f.path, + encryptedContent: f.encryptedContent, + isExecutable: f.isExecutable ?? false, + })), + }; + }, +});