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()),
|
setupCommand: v.optional(v.string()),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
}).index('by_owner', ['ownerId']),
|
}).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({
|
aiProviderProfiles: defineTable({
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { normalizeBoxPassword } from '../../convex/boxPassword';
|
||||||
|
|
||||||
|
describe('normalizeBoxPassword', () => {
|
||||||
|
it('treats null/empty as an explicit clear', () => {
|
||||||
|
expect(normalizeBoxPassword(null)).toEqual({ ok: true, value: null });
|
||||||
|
expect(normalizeBoxPassword(undefined)).toEqual({ ok: true, value: null });
|
||||||
|
expect(normalizeBoxPassword('')).toEqual({ ok: true, value: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the 8..128 length bounds', () => {
|
||||||
|
expect(normalizeBoxPassword('short')).toMatchObject({ ok: false });
|
||||||
|
expect(normalizeBoxPassword('a'.repeat(7))).toMatchObject({ ok: false });
|
||||||
|
expect(normalizeBoxPassword('a'.repeat(8))).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: 'a'.repeat(8),
|
||||||
|
});
|
||||||
|
expect(normalizeBoxPassword('a'.repeat(128))).toMatchObject({ ok: true });
|
||||||
|
expect(normalizeBoxPassword('a'.repeat(129))).toMatchObject({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects control characters that would corrupt the chpasswd stdin line', () => {
|
||||||
|
expect(normalizeBoxPassword('has\nnewline1')).toMatchObject({ ok: false });
|
||||||
|
expect(normalizeBoxPassword('has\ttab-char1')).toMatchObject({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows colons and spaces (chpasswd splits on the first colon only)', () => {
|
||||||
|
expect(normalizeBoxPassword('pa:ss wo:rd')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: 'pa:ss wo:rd',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user