migration to postgres

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:42:59 +00:00
co-authored by Claude Opus 4.6
parent 7f04ecd644
commit 500a70910e
54 changed files with 4016 additions and 264 deletions
+4 -4
View File
@@ -46,16 +46,16 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
// Check if token is blacklisted (explicit signout)
if (user.jti) {
if (isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
}
// Check if token was issued before password change
if (user.iat && user.id) {
const dbUser = getUserById(user.id);
const dbUser = await getUserById(user.id);
if (dbUser?.passwordChangedAt) {
// iat is in seconds, passwordChangedAt is in milliseconds
// iat is in seconds, passwordChangedAt is a Date
const tokenIssuedAt = user.iat * 1000;
if (tokenIssuedAt < dbUser.passwordChangedAt) {
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
throw errors.UNAUTHORIZED();
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
const token = body.token as string;
const email = body.email as string;
const userCount = getUserCount();
const userCount = await getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
if (!token) {
+2 -3
View File
@@ -13,7 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
if (isProduction) validatePassword(newPassword);
const reqUser = ctx.get('user');
const dbUser = getUserById(reqUser.id);
const dbUser = await getUserById(reqUser.id);
if (!dbUser) throw errors.UNAUTHORIZED();
@@ -23,8 +23,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
}
const newPasswordHash = await argon2.hash(newPassword);
// Use floored seconds-to-ms so the token iat (also floored) is never behind
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
const passwordChangedAt = new Date();
await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt });
const { id, email, name, role } = reqUser;
+1 -1
View File
@@ -7,7 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = getUserByEmail(email);
const dbUser = await getUserByEmail(email);
if (!dbUser) return ctx.json({ ok: true });
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
+20 -14
View File
@@ -6,7 +6,7 @@ import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import {
getUserByEmail,
getPasskeysByEmailAndOrigin,
getPasskeysByUserIdAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
@@ -41,8 +41,11 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.NOT_FOUND('User not found');
// Get existing passkeys to exclude them
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
@@ -59,7 +62,7 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
},
});
await storeChallenge(email!, origin, options.challenge);
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
return ctx.json(options);
};
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
@@ -68,10 +71,10 @@ passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostCha
const passkeyRouterPost: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const { email } = ctx.get('user') as User;
const user = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await consumeChallenge(email, origin, CHALLENGE_TTL_MS);
const storedChallenge = await consumeChallenge(user.id, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
@@ -88,7 +91,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await createPasskey({
email,
userId: user.id,
origin,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
@@ -105,7 +108,10 @@ const passkeyRouterGet: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.NOT_FOUND('User not found');
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const options = await generateAuthenticationOptions({
rpID: rpId,
@@ -115,7 +121,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
userVerification: 'preferred',
});
await storeChallenge(email!, origin, options.challenge);
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
return ctx.json(options);
};
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
@@ -127,11 +133,14 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const storedChallenge = await consumeChallenge(email!, origin, CHALLENGE_TTL_MS);
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.UNAUTHORIZED();
const storedChallenge = await consumeChallenge(dbUser.id, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
// Find the passkey being used
const dbPasskey = getPasskeyByCredentialId(email!, response.id);
const dbPasskey = await getPasskeyByCredentialId(dbUser.id, response.id);
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
@@ -152,10 +161,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
// Update counter to prevent replay attacks
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
const dbUser = getUserByEmail(email!);
if (!dbUser) throw errors.UNAUTHORIZED();
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const { id, name, username, role } = dbUser;
const token = await sign({
+1 -1
View File
@@ -10,7 +10,7 @@ export const resendVerificationHandler: Handler = async function (ctx) {
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
const user = getUserByEmail(email);
const user = await getUserByEmail(email);
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
+1 -2
View File
@@ -7,13 +7,12 @@ import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
export const resetPasswordHandler: Handler = async function (ctx) {
const now = Date.now().valueOf();
const { password, verificationCode } = ctx.get('body');
validatePassword(password);
const userInfo = (await verify(verificationCode)) as User;
if (!userInfo) throw errors.UNAUTHORIZED();
const passwordHash = await argon2.hash(password);
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: now });
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: new Date() });
return ctx.json({ ok: true });
};
+5 -4
View File
@@ -1,7 +1,7 @@
import type { Handler } from 'hono';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb';
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
@@ -13,11 +13,12 @@ const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = getUserByEmail(email);
const dbUser = await getUserByEmail(email);
if (!dbUser) throw errors.UNAUTHORIZED();
const passkeys = getPasskeysByEmailAndOrigin(email, origin);
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
if (!dbUser.password) throw errors.UNAUTHORIZED();
const { status } = dbUser;
if (status !== 'Active') throw errors.UNAUTHORIZED();
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
+1 -1
View File
@@ -13,7 +13,7 @@ export const signupHandler: Handler = async function (ctx) {
throw errors.BAD_REQUEST('Invalid email address');
}
const userCount = getUserCount();
const userCount = await getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
const dbUser = await createUser({
+3 -3
View File
@@ -1,17 +1,17 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import * as errors from '@@/custom-errors';
import { getUserById, getPasskeysByEmailAndOrigin } from 'officerdb';
import { getUserById, getPasskeysByUserIdAndOrigin } from 'officerdb';
export const usersMe: Handler = async function (ctx) {
const user = ctx.get('user') as User;
const origin = ctx.get('origin') as string;
const dbUser = getUserById(user.id);
const dbUser = await getUserById(user.id);
if (!dbUser) return errors.NOT_FOUND();
const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || '');
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin || '');
const { password, ...userWithoutPassword } = dbUser;
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
+1 -1
View File
@@ -22,7 +22,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
const user = getUserById(userInfo.id);
const user = await getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
// Reset-password tokens skip the verification status check
+2 -2
View File
@@ -11,7 +11,7 @@ export const verifyHandler: Handler = async function (ctx) {
const userInfo = (await verifyJwt(verificationCode)) as User;
if (!userInfo) throw errors.BAD_REQUEST();
const user = getUserById(userInfo.id);
const user = await getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
const updates: Record<string, unknown> = { status: 'Active' };
@@ -38,7 +38,7 @@ export const verifyHandler: Handler = async function (ctx) {
await updateUser(userInfo.id, updates);
// Re-fetch user to get final values after update
const finalUser = getUserById(userInfo.id);
const finalUser = await getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Issue a token so the user is logged in immediately
+1 -1
View File
@@ -267,7 +267,7 @@ async function validateJwt(req: Request): Promise<boolean> {
try {
const payload = await verify(token);
if (!payload) return false;
if (payload.jti && isTokenBlacklisted(payload.jti)) return false;
if (payload.jti && await isTokenBlacklisted(payload.jti)) return false;
return true;
} catch {
return false;
@@ -4,6 +4,6 @@ import { getUserCount } from 'officerdb';
export const landingPageDataRouter = createRouter();
landingPageDataRouter.get('/', async (ctx) => {
const userCount = getUserCount();
const userCount = await getUserCount();
return ctx.json({ registrationOpen: userCount === 0 });
});
+46 -8
View File
@@ -5,8 +5,9 @@ import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono";
import { readSearxngConfig } from "../server-settings/searxng";
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { getServerIntegration, getUserIntegration } from "officerdb";
import { logger } from "./logger";
import { parseFrontmatter } from "../skills/skills";
@@ -159,12 +160,33 @@ function buildResourcesEnv(): string {
return JSON.stringify(result);
}
function getGoogleConfigPath(): string {
return join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
async function ensureGoogleConfigFile(): Promise<string> {
const filePath = join(SERVER_CONFIG_DIR, 'google-oauth.json');
try {
const integration = await getServerIntegration('google');
if (integration?.config) {
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
}
} catch {
// No google config available
}
return filePath;
}
function getGoogleTokenPath(email: string): string {
return join(DATA_PATH, email, 'integrations', 'google.json');
async function ensureGoogleTokenFile(userId: number, email: string): Promise<string> {
const dir = join(DATA_PATH, email, 'integrations');
const filePath = join(dir, 'google.json');
try {
const integration = await getUserIntegration(userId, 'google');
if (integration?.config) {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
}
} catch {
// No user google integration available
}
return filePath;
}
type SandboxOptions = {
@@ -177,6 +199,7 @@ type SandboxOptions = {
export async function spawnPi(
cwd: string,
model: string,
userId: number,
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
@@ -217,8 +240,8 @@ export async function spawnPi(
const resourcesEnv = buildResourcesEnv();
const googleConfigHost = getGoogleConfigPath();
const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations');
const googleConfigHost = await ensureGoogleConfigFile();
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
const envFlags = [
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
@@ -275,13 +298,28 @@ export async function spawnPi(
}
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const googleConfigPath = await ensureGoogleConfigFile();
const googleTokenPath = await ensureGoogleTokenFile(userId, email);
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...storedKeys, HOME: getHomeDir(email), OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_GOOGLE_CONFIG_PATH: getGoogleConfigPath(), OFFICER_GOOGLE_TOKEN_PATH: getGoogleTokenPath(email), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db') },
env: {
...process.env,
...storedKeys,
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath,
OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
},
});
logger.info('Spawned Pi locally', {
+10 -12
View File
@@ -6,22 +6,20 @@ import * as storage from './storage';
import * as piBridge from './pi-bridge';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { getHomeDir, getUserSettingsFile } from '../../../servers/data-path';
import { getHomeDir } from '../../../servers/data-path';
import { getUserSettings } from 'officerdb';
import { logger } from './logger';
// Default model when no user preference is set
const DEFAULT_MODEL = 'opencode/big-pickle';
async function getUserDefaultModel(email: string): Promise<string | null> {
async function getUserDefaultModel(userId: number): Promise<string | null> {
try {
const settingsPath = getUserSettingsFile(email);
const file = Bun.file(settingsPath);
if (await file.exists()) {
const settings = await file.json();
return settings?.chat?.defaultModel || null;
}
const settings = await getUserSettings(userId);
const chat = settings?.chat as Record<string, unknown> | undefined;
return (chat?.defaultModel as string) || null;
} catch (err) {
logger.error('Failed to read user settings for default model', { email, error: String(err) });
logger.error('Failed to read user settings for default model', { userId, error: String(err) });
}
return null;
}
@@ -254,7 +252,7 @@ async function handleChat(
let modelSource = 'client-provided';
let userDefault = null;
if (!model) {
userDefault = await getUserDefaultModel(email);
userDefault = await getUserDefaultModel(userId);
if (userDefault) {
model = userDefault;
modelSource = 'user-settings';
@@ -288,7 +286,7 @@ async function handleChat(
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -363,7 +361,7 @@ async function handleResume(
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
+5 -5
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { settingsPath } from './server-settings';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type OcrConfig = {
@@ -10,8 +10,8 @@ type OcrConfig = {
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
const config = await readResourceConfig('optical-character-recognition');
if (config.url) return { url: config.url, model: config.model ?? '' };
// Fallback to legacy settings.json
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.ocr as OcrConfig | undefined;
}
@@ -25,9 +25,9 @@ ocrRouter.get('/', async (ctx) => {
ocrRouter.put('/', async (ctx) => {
const body = await ctx.req.json<OcrConfig>();
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const settings = await readServerSettings();
settings.ocr = body;
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
await writeServerSettings(settings);
return ctx.json({ success: true });
});
@@ -1,8 +1,7 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { SERVER_CONFIG_DIR } from '@@/data-path';
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { claudeCodeRouter } from './claude-code';
import { opencodeRouter } from './opencode';
import { piMonoRouter } from './pi-mono';
@@ -14,14 +13,6 @@ import { sttRouter } from './stt';
import { ocrRouter } from './ocr';
import { searxngRouter } from './searxng';
export const settingsPath = `${SERVER_CONFIG_DIR}/server-settings.json`;
const settingsFile = Bun.file(settingsPath);
if (!(await settingsFile.exists())) {
await mkdir(SERVER_CONFIG_DIR, { recursive: true });
await Bun.write(settingsPath, '{}');
}
export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
@@ -35,33 +26,29 @@ serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
serverSettingsRouter.route('/searxng', searxngRouter);
export const readSettings = async () => {
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
};
export { readServerSettings as readSettings };
serverSettingsRouter.get('/settings', async (ctx) => {
return ctx.json(await readSettings());
return ctx.json(await readServerSettings());
});
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
const settings = await readSettings();
const settings = await readServerSettings();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
});
serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json();
const settings = await readSettings();
const settings = await readServerSettings();
const updated = { ...settings, ...body };
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
await writeServerSettings(updated);
return ctx.json(updated);
});
serverSettingsRouter.get('/plugins', async (ctx) => {
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
const settings = await Bun.file(settingsPath)
.json()
.catch(() => ({}));
const pluginSettings: Record<string, boolean> = settings.plugins ?? {};
const settings = await readServerSettings();
const pluginSettings: Record<string, boolean> = (settings.plugins as Record<string, boolean>) ?? {};
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
+8 -8
View File
@@ -1,6 +1,6 @@
import { createTransport } from 'nodemailer';
import { createRouter } from '../../create-router';
import { settingsPath } from './server-settings';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { getTransport } from 'emailer';
type SmtpConfig = {
@@ -46,24 +46,24 @@ function buildTransportUrl(config: SmtpConfig): string {
export const smtpRouter = createRouter();
smtpRouter.get('/', async (ctx) => {
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const smtp: SmtpConfig | undefined = settings.smtp;
const settings = await readServerSettings();
const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
if (smtp) return ctx.json(serializeConfig(smtp));
return ctx.json(null);
});
smtpRouter.put('/', async (ctx) => {
const body = await ctx.req.json<SmtpConfig>();
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const settings = await readServerSettings();
const existing: SmtpConfig | undefined = settings.smtp;
const existing: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
if (existing) {
if (body.apiKey && body.apiKey.includes('****')) body.apiKey = existing.apiKey;
if (body.password && body.password.includes('****')) body.password = existing.password;
}
settings.smtp = body;
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
await writeServerSettings(settings);
return ctx.json({ success: true });
});
@@ -95,8 +95,8 @@ smtpRouter.post('/test', async (ctx) => {
if (!body.to) return ctx.json({ error: 'Recipient address required' }, 400);
// Resolve masked secrets from saved config
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const saved: SmtpConfig | undefined = settings.smtp;
const settings = await readServerSettings();
const saved: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
if (saved) {
if (body.apiKey?.includes('****')) body.apiKey = saved.apiKey;
if (body.password?.includes('****')) body.password = saved.password;
+5 -5
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { settingsPath } from './server-settings';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type SttConfig = {
@@ -9,8 +9,8 @@ type SttConfig = {
export async function readSttConfig(): Promise<SttConfig | undefined> {
const config = await readResourceConfig('speech-to-text');
if (config.url) return { url: config.url };
// Fallback to legacy settings.json
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.stt as SttConfig | undefined;
}
@@ -24,9 +24,9 @@ sttRouter.get('/', async (ctx) => {
sttRouter.put('/', async (ctx) => {
const body = await ctx.req.json<SttConfig>();
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const settings = await readServerSettings();
settings.stt = body;
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
await writeServerSettings(settings);
return ctx.json({ success: true });
});
@@ -143,7 +143,7 @@ export async function syncUserPiConfig(email: string): Promise<void> {
}
export async function syncAllUserPiConfigs(): Promise<void> {
const users = getUsers();
const users = await getUsers();
if (users.length === 0) return;
const [appConfig, policy, apiKeys] = await Promise.all([
+8 -8
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { settingsPath } from './server-settings';
import { readServerSettings, writeServerSettings } from 'officerdb';
import { readResourceConfig } from './resources';
type TtsConfig = {
@@ -18,8 +18,8 @@ function maskSecret(value: string | undefined): string | undefined {
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
const config = await readResourceConfig('text-to-speech');
if (!config.url && !config.provider) {
// Fallback to legacy settings.json
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
// Fallback to legacy DB settings
const settings = await readServerSettings();
return settings.tts as TtsConfig | undefined;
}
return {
@@ -41,15 +41,15 @@ ttsRouter.get('/', async (ctx) => {
ttsRouter.put('/', async (ctx) => {
const body = await ctx.req.json<TtsConfig>();
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const settings = await readServerSettings();
const existing: TtsConfig | undefined = settings.tts;
const existing: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
if (existing && body.apiKey?.includes('****')) {
body.apiKey = existing.apiKey;
}
settings.tts = body;
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
await writeServerSettings(settings);
return ctx.json({ success: true });
});
@@ -152,8 +152,8 @@ async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[];
ttsRouter.post('/test', async (ctx) => {
const body = await ctx.req.json<TtsConfig>();
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
const saved: TtsConfig | undefined = settings.tts;
const settings = await readServerSettings();
const saved: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
if (saved && body.apiKey?.includes('****')) {
body.apiKey = saved.apiKey;
}
+17 -52
View File
@@ -1,11 +1,9 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { getUserSettingsFile, getUserStateFile } from '@@/data-path';
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
const DEFAULT_SETTINGS = {
chat: {
defaultProvider: 'claude',
defaultProvider: 'pi',
defaultModel: null,
systemPrompt: '',
temperature: 1,
@@ -16,73 +14,40 @@ const DEFAULT_SETTINGS = {
},
};
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
export const settingsRouter = createRouter();
// GET /settings — return settings.json, auto-create with defaults if missing
// GET /settings — return user settings from DB, default if empty
settingsRouter.get('/settings', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserSettingsFile(email);
const file = Bun.file(filePath);
const userId = ctx.get('user').id;
const settings = await getUserSettings(userId);
if (await file.exists()) {
try {
return ctx.json(await file.json());
} catch {
// corrupted — fall through to defaults
}
if (Object.keys(settings).length === 0) {
await setUserSettings(userId, DEFAULT_SETTINGS);
return ctx.json(DEFAULT_SETTINGS);
}
await ensureDir(filePath);
await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2));
return ctx.json(DEFAULT_SETTINGS);
return ctx.json(settings);
});
// PUT /settings — full replacement
settingsRouter.put('/settings', async (ctx) => {
const email = ctx.get('user').email;
const userId = ctx.get('user').id;
const body = ctx.get('body');
const filePath = getUserSettingsFile(email);
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(body, null, 2));
await setUserSettings(userId, body);
return ctx.json(body);
});
// GET /state — return state.json, auto-create with {} if missing
// GET /state — return user state from DB
settingsRouter.get('/state', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserStateFile(email);
const file = Bun.file(filePath);
if (await file.exists()) {
try {
return ctx.json(await file.json());
} catch {
// corrupted — fall through to empty
}
}
await ensureDir(filePath);
await Bun.write(file, JSON.stringify({}, null, 2));
return ctx.json({});
const userId = ctx.get('user').id;
const state = await getUserState(userId);
return ctx.json(state);
});
// PATCH /state — shallow-merge incoming keys
settingsRouter.patch('/state', async (ctx) => {
const email = ctx.get('user').email;
const userId = ctx.get('user').id;
const body = ctx.get('body');
const filePath = getUserStateFile(email);
const file = Bun.file(filePath);
let existing: Record<string, unknown> = {};
if (await file.exists()) {
try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
}
const merged = { ...existing, ...body };
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(merged, null, 2));
const merged = await patchUserState(userId, body);
return ctx.json(merged);
});
+30 -8
View File
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR } from '@@/data-path';
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
import { getUsers } from 'officerdb';
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type ShellInfo = { command: string; args: string[]; name: string };
@@ -113,10 +113,10 @@ const containerHasExpectedMounts = (dockerId: string): boolean => {
});
if (result.exitCode !== 0) return false;
const mounts = result.stdout.toString();
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('google-oauth.json');
return mounts.includes(getGlobalSkillsDir());
};
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => {
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string): Promise<{ dockerId: string }> => {
ensureDockerImage();
const dockerPath = Bun.which('docker') ?? 'docker';
const dockerId = `officer-terminal-${userId}`;
@@ -138,10 +138,32 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
}
const containerHome = `/home/${username}`;
// Write google-oauth config from DB to files for Docker mount
const googleConfigHost = join(SERVER_CONFIG_DIR, 'google-oauth.json');
const googleMounts: string[] = existsSync(googleConfigHost)
? ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`]
: [];
let googleMounts: string[] = [];
try {
const googleIntegration = await getServerIntegration('google');
if (googleIntegration?.config) {
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
writeFileSync(googleConfigHost, JSON.stringify(googleIntegration.config, null, 2));
googleMounts = ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`];
}
} catch {
// No google config — skip mount
}
// Write per-user google token from DB for Docker mount
const userIntegrationsDir = join(DATA_PATH, email, 'integrations');
try {
const userGoogle = await getUserIntegration(userId, 'google');
if (userGoogle?.config) {
mkdirSync(userIntegrationsDir, { recursive: true });
writeFileSync(join(userIntegrationsDir, 'google.json'), JSON.stringify(userGoogle.config, null, 2));
}
} catch {
// No user google integration — skip
}
const run = Bun.spawnSync({
cmd: [
@@ -273,7 +295,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
}
const port = existing?.port ?? getAvailablePort(map, userId);
const docker = startDockerSidecar(port, homeDir, userId, username, email);
const docker = await startDockerSidecar(port, homeDir, userId, username, email);
const next = { userId, email, dockerId: docker.dockerId, port };
map[email] = next;
await saveContainerMap(map);
@@ -321,7 +343,7 @@ const startHostSidecar = async () => {
export const initTerminalSidecars = async () => {
await startHostSidecar();
ensureDockerImage();
const users = getUsers();
const users = await getUsers();
for (const user of users) {
const homeDir = getHomeDir(user.email);
mkdirSync(dirname(homeDir), { recursive: true });
+4 -4
View File
@@ -15,7 +15,7 @@ usersRouter.get('/', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
const users = getUsers();
const users = await getUsers();
const sanitized = users.map(({ password, ...rest }) => rest);
return ctx.json(sanitized);
@@ -39,7 +39,7 @@ usersRouter.post('/invite', async (ctx) => {
? (role as (typeof USER_ROLES)[number])
: ('Member' as const);
const existing = getUserByEmail(email);
const existing = await getUserByEmail(email);
if (existing) throw errors.CONFLICT('A user with this email already exists');
const dbUser = await createUser({
@@ -71,7 +71,7 @@ usersRouter.post('/:id/resend-invite', async (ctx) => {
const id = Number(ctx.req.param('id'));
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
const target = getUserById(id);
const target = await getUserById(id);
if (!target) throw errors.NOT_FOUND('User not found');
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
@@ -98,7 +98,7 @@ usersRouter.delete('/:id', async (ctx) => {
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
const target = getUserById(id);
const target = await getUserById(id);
if (!target) throw errors.NOT_FOUND('User not found');
await deleteUser(id);
+1 -4
View File
@@ -4,7 +4,6 @@ import { homedir } from 'node:os';
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
import { initAuthStore } from 'officerdb';
import { syncSeedSkills } from './sync-skills';
import { syncSeedTools } from './sync-tools';
import { syncSeedExtensions } from './sync-extensions';
@@ -16,8 +15,6 @@ import { initQueue } from './queue';
mkdirSync(DATA_PATH, { recursive: true });
mkdirSync(PI_CONFIG_DIR, { recursive: true });
await initAuthStore();
async function ensurePiInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
@@ -80,7 +77,7 @@ function seedPiConfig(): void {
syncSeedTools();
syncSeedExtensions();
syncSeedResources();
migrateSettingsToResources();
await migrateSettingsToResources();
generateResourceSkill(DATA_PATH);
await syncLocalProvidersToPiConfig().catch(err => {
+2 -2
View File
@@ -51,8 +51,8 @@ honoServer.route('/api/waitlist', waitlistRouter);
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => {
const { readSettings } = await import('./api/server-settings/server-settings');
const settings = await readSettings();
const { readServerSettings } = await import('officerdb');
const settings = await readServerSettings();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
});
+5 -5
View File
@@ -1,7 +1,7 @@
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, SEED_PATH } from './data-path';
import { settingsPath } from './api/server-settings/server-settings';
import { readServerSettings } from 'officerdb';
const SETTINGS_TO_RESOURCE: Record<string, string> = {
stt: 'speech-to-text',
@@ -9,10 +9,10 @@ const SETTINGS_TO_RESOURCE: Record<string, string> = {
ocr: 'optical-character-recognition',
};
export function migrateSettingsToResources(): void {
let settings: Record<string, Record<string, string>> = {};
export async function migrateSettingsToResources(): Promise<void> {
let settings: Record<string, Record<string, string>>;
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
settings = (await readServerSettings()) as Record<string, Record<string, string>>;
} catch {
return;
}
+17 -15
View File
@@ -1,9 +1,9 @@
import { join } from 'node:path';
import type { Database } from 'bun:sqlite';
import type { JobHandler } from '../types';
import { registerHandler } from '../handler-registry';
import { DATA_PATH, SERVER_CONFIG_DIR } from '../../data-path';
import { DATA_PATH } from '../../data-path';
import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta, updateEmailLabels } from '../../api/email/email-db';
import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb';
type GoogleCredentials = {
accessToken: string;
@@ -13,26 +13,28 @@ type GoogleCredentials = {
clientSecret: string;
};
const googleConfigPath = join(SERVER_CONFIG_DIR, 'google-oauth.json');
async function loadCredentials(userId: string): Promise<GoogleCredentials> {
const config = await Bun.file(googleConfigPath).json().catch(() => null);
if (!config?.clientId || !config?.clientSecret) {
async function loadCredentials(email: string): Promise<GoogleCredentials> {
const serverGoogle = await getServerIntegration('google');
const serverConfig = serverGoogle?.config as Record<string, unknown> | undefined;
if (!serverConfig?.clientId || !serverConfig?.clientSecret) {
throw new Error('Google OAuth not configured — ask your admin to set up credentials');
}
const tokenPath = join(DATA_PATH, userId, 'integrations', 'google.json');
const token = await Bun.file(tokenPath).json().catch(() => null);
if (!token?.accessToken) {
const dbUser = await getUserByEmail(email);
if (!dbUser) throw new Error('User not found');
const userGoogle = await getUserIntegration(dbUser.id, 'google');
const userConfig = userGoogle?.config as Record<string, unknown> | undefined;
if (!userConfig?.accessToken) {
throw new Error('Google account not connected — connect in Settings → Integrations');
}
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken ?? '',
expiresAt: token.expiresAt ?? 0,
clientId: config.clientId,
clientSecret: config.clientSecret,
accessToken: userConfig.accessToken as string,
refreshToken: (userConfig.refreshToken as string) ?? '',
expiresAt: (userConfig.expiresAt as number) ?? 0,
clientId: serverConfig.clientId as string,
clientSecret: serverConfig.clientSecret as string,
};
}