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
This commit is contained in:
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
|
|||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Linux username for the per-user container home (/home/<username>). 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('/');
|
||||||
|
};
|
||||||
|
|||||||
@@ -348,6 +348,30 @@ const applicationTables = {
|
|||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_provider', ['userId', 'provider']),
|
.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({
|
aiProviderProfiles: defineTable({
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user