feat(backend): boxSettings table + box password actions
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user