feat(backend): boxSettings table + box password actions

This commit is contained in:
Gabriel Brown
2026-07-13 09:56:08 -04:00
parent 37a82098c2
commit a956d63e16
5 changed files with 198 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
// Pure validation for the box user's password, shared by the Node action and
// unit tests. Kept free of Convex imports so it tests without the harness.
export type BoxPasswordValidation =
| { ok: true; value: string | null }
| { ok: false; error: string };
// null/'' is an explicit clear (sudo reverts to NOPASSWD). The applied value
// travels to `chpasswd` as a `user:password` stdin line, so control characters
// (including newline/tab) are rejected; colons are fine — chpasswd splits on
// the first colon only.
export const normalizeBoxPassword = (
password: string | null | undefined,
): BoxPasswordValidation => {
if (password == null || password === '') return { ok: true, value: null };
if (password.length < 8) {
return { ok: false, error: 'Password must be at least 8 characters.' };
}
if (password.length > 128) {
return { ok: false, error: 'Password must be at most 128 characters.' };
}
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
if (/[\u0000-\u001f\u007f]/.test(password)) {
return {
ok: false,
error: 'Password must not contain control characters.',
};
}
return { ok: true, value: password };
};
+77
View File
@@ -0,0 +1,77 @@
import { v } from 'convex/values';
import { internalMutation, internalQuery, query } from './_generated/server';
import { deriveHomeUsername, getRequiredUserId } from './model';
// Per-user box account settings. The password itself is write-only from the
// client's perspective: browsers only ever learn whether one is set; the
// plaintext flows exclusively to the worker via boxSettingsNode.
export const hasMine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const row = await ctx.db
.query('boxSettings')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.unique();
return { hasPassword: Boolean(row?.boxPasswordEncrypted) };
},
});
// Resolve the authed caller's owner id + home username (same derivation as
// userEnvironment.getMine) for the Node action, which cannot touch ctx.db.
export const resolveOwnerInternal = internalQuery({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const [user, settings] = await Promise.all([
ctx.db.get(ownerId),
ctx.db
.query('userEnvironment')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.unique(),
]);
return {
ownerId,
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
};
},
});
// Worker-facing lookup at box creation. `.first()` (not `.unique()`): derived
// usernames are not guaranteed globally unique, and a throw here would brick
// box creation for both colliding users.
export const getByUsernameInternal = internalQuery({
args: { username: v.string() },
handler: async (ctx, args) =>
await ctx.db
.query('boxSettings')
.withIndex('by_username', (q) => q.eq('username', args.username))
.first(),
});
export const upsertMineInternal = internalMutation({
args: {
ownerId: v.id('users'),
username: v.string(),
boxPasswordEncrypted: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('boxSettings')
.withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId))
.unique();
const patch = {
username: args.username,
// Explicit undefined clears the field on patch (password removed).
boxPasswordEncrypted: args.boxPasswordEncrypted,
updatedAt: Date.now(),
};
if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert('boxSettings', { ownerId: args.ownerId, ...patch });
}
},
});
@@ -0,0 +1,45 @@
'use node';
import { ConvexError, v } from 'convex/values';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { normalizeBoxPassword } from './boxPassword';
import { decryptSecret, encryptSecret } from './secretCrypto';
import { assertWorkerToken } from './workerAuth';
/** Authed: store (or clear, with null/'') the caller's box password. */
export const setBoxPassword = action({
args: { password: v.union(v.string(), v.null()) },
handler: async (ctx, args): Promise<{ hasPassword: boolean }> => {
const normalized = normalizeBoxPassword(args.password);
if (!normalized.ok) throw new ConvexError(normalized.error);
const { ownerId, username } = await ctx.runQuery(
internal.boxSettings.resolveOwnerInternal,
{},
);
await ctx.runMutation(internal.boxSettings.upsertMineInternal, {
ownerId,
username,
boxPasswordEncrypted:
normalized.value == null ? undefined : encryptSecret(normalized.value),
});
return { hasPassword: normalized.value != null };
},
});
/** Worker-facing: the stored password (decrypted) for a box username. */
export const getBoxPasswordForWorker = action({
args: { workerToken: v.string(), username: v.string() },
handler: async (ctx, args): Promise<{ password: string | null }> => {
assertWorkerToken(args.workerToken);
const row = await ctx.runQuery(internal.boxSettings.getByUsernameInternal, {
username: args.username,
});
return {
password: row?.boxPasswordEncrypted
? decryptSecret(row.boxPasswordEncrypted)
: null,
};
},
});
+12
View File
@@ -376,6 +376,18 @@ const applicationTables = {
setupCommand: v.optional(v.string()),
updatedAt: v.number(),
}).index('by_owner', ['ownerId']),
// Per-user box account settings (the Linux user inside spoon-box-<username>).
boxSettings: defineTable({
ownerId: v.id('users'),
// Resolved Spoon home username at the time the row was written — the
// worker looks the password up by this at box creation (usernames are not
// indexed anywhere else).
username: v.string(),
boxPasswordEncrypted: v.optional(v.string()),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_username', ['username']),
aiProviderProfiles: defineTable({
ownerId: v.id('users'),
name: v.string(),