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
+1
View File
@@ -21,6 +21,7 @@
"build:landing": "bun run ./scripts/build/landing.ts",
"db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "cd src/databases/officer_db && bun run push",
"db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
+138
View File
@@ -0,0 +1,138 @@
/**
* Migration script: auth data from JSON files → PostgreSQL
*
* Migrates:
* - users.json → users table
* - passkeys.json → passkeys table (email → userId FK)
* - token-blacklist.json → token_blacklist table
*
* Usage: bun run scripts/migrate-auth-to-pg.ts
*/
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { users, passkeys, tokenBlacklist } from 'officerdb/schema';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const AUTH_DIR = join(DATA_PATH, 'auth');
type OldUser = {
id: number;
email: string;
password: string | null;
role: string;
status: string;
name: string | null;
username: string | null;
avatar: string | null;
passwordChangedAt: number | null;
};
type OldPasskey = {
id: number;
email: string;
origin: string | null;
credentialId: string | null;
publicKey: string | null;
counter: number;
};
type OldBlacklistEntry = {
jti: string;
expiresAt: number;
};
async function readJson<T>(path: string, fallback: T): Promise<T> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return fallback;
return (await file.json()) as T;
} catch {
return fallback;
}
}
async function migrate() {
console.log(`[migrate] Reading JSON files from ${AUTH_DIR}`);
const oldUsers = await readJson<OldUser[]>(join(AUTH_DIR, 'users.json'), []);
const oldPasskeys = await readJson<OldPasskey[]>(join(AUTH_DIR, 'passkeys.json'), []);
const oldBlacklist = await readJson<OldBlacklistEntry[]>(join(AUTH_DIR, 'token-blacklist.json'), []);
console.log(`[migrate] Found: ${oldUsers.length} users, ${oldPasskeys.length} passkeys, ${oldBlacklist.length} blacklisted tokens`);
if (oldUsers.length === 0) {
console.log('[migrate] No users to migrate. Done.');
process.exit(0);
}
// Build email → userId map for passkey migration
const emailToUserId = new Map<string, number>();
// Migrate users
console.log('[migrate] Migrating users...');
for (const u of oldUsers) {
const [inserted] = await db
.insert(users)
.values({
email: u.email,
password: u.password,
role: u.role as 'Member' | 'Admin' | 'Owner' | 'Super Admin',
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
name: u.name,
username: u.username,
avatar: u.avatar,
passwordChangedAt: u.passwordChangedAt ? new Date(u.passwordChangedAt) : null,
})
.returning();
emailToUserId.set(u.email, inserted!.id);
console.log(` [user] ${u.email} (old id=${u.id} → new id=${inserted!.id})`);
}
// Migrate passkeys
if (oldPasskeys.length > 0) {
console.log('[migrate] Migrating passkeys...');
for (const p of oldPasskeys) {
const userId = emailToUserId.get(p.email);
if (!userId) {
console.warn(` [passkey] Skipping passkey for unknown email: ${p.email}`);
continue;
}
await db.insert(passkeys).values({
userId,
origin: p.origin,
credentialId: p.credentialId,
publicKey: p.publicKey,
counter: p.counter,
});
console.log(` [passkey] ${p.email} / ${p.origin}`);
}
}
// Migrate token blacklist
if (oldBlacklist.length > 0) {
const now = Math.floor(Date.now() / 1000);
const active = oldBlacklist.filter((b) => b.expiresAt >= now);
console.log(`[migrate] Migrating ${active.length} active blacklisted tokens (${oldBlacklist.length - active.length} expired, skipped)...`);
for (const b of active) {
await db
.insert(tokenBlacklist)
.values({
jti: b.jti,
expiresAt: new Date(b.expiresAt * 1000),
})
.onConflictDoNothing();
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Migration script: server-settings.json → PostgreSQL server_config table
*
* Usage: bun run scripts/migrate-server-settings-to-pg.ts
*/
import { join } from 'node:path';
import { writeServerSettings } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const settingsPath = join(DATA_PATH, 'server-settings', 'server-settings.json');
async function migrate() {
console.log(`[migrate] Reading ${settingsPath}`);
const file = Bun.file(settingsPath);
if (!(await file.exists())) {
console.log('[migrate] No server-settings.json found. Done.');
process.exit(0);
}
let settings: Record<string, unknown>;
try {
settings = await file.json();
} catch {
console.log('[migrate] Could not parse server-settings.json. Done.');
process.exit(0);
}
const keys = Object.keys(settings);
console.log(`[migrate] Found ${keys.length} keys: ${keys.join(', ')}`);
await writeServerSettings(settings);
console.log('[migrate] Written to server_config table.');
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Migration script: per-user settings.json and state.json → PostgreSQL
*
* Reads from $DATA_PATH/{email}/settings/settings.json and state/state.json
* Writes to user_settings and user_state tables
*
* Usage: bun run scripts/migrate-user-settings-to-pg.ts
*/
import { join } from 'node:path';
import { readdirSync } from 'node:fs';
import { getUserByEmail, setUserSettings, patchUserState } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
async function readJson<T>(path: string): Promise<T | null> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return null;
return (await file.json()) as T;
} catch {
return null;
}
}
async function migrate() {
console.log(`[migrate] Scanning ${DATA_PATH} for user data dirs`);
// User data dirs are named by email (contain @)
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
const userDirs = entries.filter((e) => e.isDirectory() && e.name.includes('@'));
console.log(`[migrate] Found ${userDirs.length} user dirs: ${userDirs.map((d) => d.name).join(', ')}`);
for (const dir of userDirs) {
const email = dir.name;
const dbUser = await getUserByEmail(email);
if (!dbUser) {
console.warn(` [skip] ${email} — no matching user in DB`);
continue;
}
// Settings
const settingsPath = join(DATA_PATH, email, 'settings', 'settings.json');
const settings = await readJson<Record<string, unknown>>(settingsPath);
if (settings && Object.keys(settings).length > 0) {
await setUserSettings(dbUser.id, settings);
console.log(` [settings] ${email}${Object.keys(settings).length} keys`);
}
// State
const statePath = join(DATA_PATH, email, 'state', 'state.json');
const state = await readJson<Record<string, unknown>>(statePath);
if (state && Object.keys(state).length > 0) {
await patchUserState(dbUser.id, state);
console.log(` [state] ${email}${Object.keys(state).length} keys`);
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'drizzle-kit';
const envPath = resolve(__dirname, '../../../.env');
try {
const text = readFileSync(envPath, 'utf-8');
for (const line of text.split('\n')) {
const match = line.match(/^(\w+)=(.*)$/);
if (match) {
const [, key, val] = match;
if (!process.env[key!]) process.env[key!] = val!.replace(/^["']|["']$/g, '');
}
}
} catch {}
export default defineConfig({
schema: './src/schema/index.ts',
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.POSTGRES_URL!,
},
});
@@ -0,0 +1,339 @@
CREATE TABLE "passkey_challenges" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"origin" text NOT NULL,
"challenge" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "passkeys" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"origin" text,
"credential_id" text,
"public_key" text,
"counter" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "token_blacklist" (
"jti" text PRIMARY KEY NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"password" text,
"role" text DEFAULT 'Member' NOT NULL,
"status" text DEFAULT 'Unverified' NOT NULL,
"name" text,
"username" text,
"avatar" text,
"password_changed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "dock_configs" (
"user_id" integer PRIMARY KEY NOT NULL,
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"profile" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider")
);
--> statement-breakpoint
CREATE TABLE "user_settings" (
"user_id" integer PRIMARY KEY NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_state" (
"user_id" integer PRIMARY KEY NOT NULL,
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_groups" (
"slug" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_messages" (
"id" text PRIMARY KEY NOT NULL,
"session_id" text NOT NULL,
"role" text NOT NULL,
"text" text,
"model" text,
"tool_name" text,
"tool_input" jsonb,
"tool_call_id" text,
"output" text,
"is_error" boolean DEFAULT false,
"cost_input_tokens" integer,
"cost_output_tokens" integer,
"cost_total_usd" numeric(12, 6),
"sort_order" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "chat_sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"context" text DEFAULT 'chat' NOT NULL,
"context_id" text,
"title" text DEFAULT 'New Chat' NOT NULL,
"model" text,
"cwd" text,
"thinking" text,
"archived" boolean DEFAULT false NOT NULL,
"group_slug" text,
"message_count" integer DEFAULT 0 NOT NULL,
"cost_input_tokens" integer DEFAULT 0 NOT NULL,
"cost_output_tokens" integer DEFAULT 0 NOT NULL,
"cost_total_usd" numeric(12, 6) DEFAULT '0' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"slug" text NOT NULL,
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
);
--> statement-breakpoint
CREATE TABLE "screens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"host_terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_workspaces_user_id" UNIQUE("user_id","id")
);
--> statement-breakpoint
CREATE TABLE "extensions" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"user_id" integer,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"implementation" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_extensions_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "item_chats" (
"id" text PRIMARY KEY NOT NULL,
"item_type" text NOT NULL,
"item_id" integer NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_item_chats_type_item" UNIQUE("item_type","item_id")
);
--> statement-breakpoint
CREATE TABLE "processes" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"user_id" integer,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_processes_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "resources" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_resources_scope_dir" UNIQUE("scope","dir_name")
);
--> statement-breakpoint
CREATE TABLE "skills" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"user_id" integer,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_skills_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "tasks" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"user_id" integer,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"tags" jsonb,
"tools" jsonb,
"skills" jsonb,
"inputs" jsonb,
"outputs" jsonb,
"dependencies" jsonb,
"config" jsonb,
"trigger" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_tasks_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "tools" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"user_id" integer,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"label" text,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"language" text,
"inputs" jsonb,
"implementation" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_tools_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "queue_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"lane" text NOT NULL,
"type" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"current_step" integer DEFAULT 0 NOT NULL,
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
"meta" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "task_logs" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_name" text NOT NULL,
"task_dir_name" text NOT NULL,
"entry_name" text NOT NULL,
"entry_type" text NOT NULL,
"provider" text NOT NULL,
"model" text NOT NULL,
"is_error" boolean DEFAULT false NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "terminal_containers" (
"user_id" integer PRIMARY KEY NOT NULL,
"docker_id" text NOT NULL,
"port" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_config" (
"key" text PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "processes" ADD CONSTRAINT "processes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "skills" ADD CONSTRAINT "skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tools" ADD CONSTRAINT "tools_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_chat_groups_user" ON "chat_groups" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_messages_session_order" ON "chat_messages" USING btree ("session_id","sort_order");--> statement-breakpoint
CREATE INDEX "idx_sessions_user_context" ON "chat_sessions" USING btree ("user_id","context");--> statement-breakpoint
CREATE INDEX "idx_sessions_user_created" ON "chat_sessions" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_sessions_user_group" ON "chat_sessions" USING btree ("user_id","group_slug");--> statement-breakpoint
CREATE INDEX "idx_extensions_scope" ON "extensions" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_extensions_user" ON "extensions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_item_chats_type_item" ON "item_chats" USING btree ("item_type","item_id");--> statement-breakpoint
CREATE INDEX "idx_processes_scope" ON "processes" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_processes_user" ON "processes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_resources_scope" ON "resources" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_skills_scope" ON "skills" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_skills_user" ON "skills" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_tasks_scope" ON "tasks" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_tasks_user" ON "tasks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_tools_scope" ON "tools" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_tools_user" ON "tools" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint
CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1772121115932,
"tag": "0000_clammy_meteorite",
"breakpoints": true
}
]
}
+9 -1
View File
@@ -5,7 +5,15 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts"
"./types": "./src/types.ts",
"./db": "./src/db.ts",
"./schema": "./src/schema/index.ts"
},
"scripts": {
"generate": "drizzle-kit generate --config=drizzle.config.ts",
"push": "drizzle-kit push --config=drizzle.config.ts",
"migrate": "drizzle-kit migrate --config=drizzle.config.ts",
"studio": "drizzle-kit studio --config=drizzle.config.ts"
},
"license": "MIT",
"dependencies": {
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const { POSTGRES_URL } = process.env;
if (!POSTGRES_URL) {
throw new Error('POSTGRES_URL environment variable is required');
}
const client = postgres(POSTGRES_URL);
export const db = drizzle(client, { schema });
@@ -0,0 +1,112 @@
import { eq, and, lt, sql } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys, passkeyChallenges, tokenBlacklist } from '../schema';
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
// ── Users ──
export async function getUsers(): Promise<UserSelect[]> {
return db.select().from(users);
}
export async function getUserById(id: number): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user;
}
export async function getUserByEmail(email: string): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.email, email));
return user;
}
export async function getUserCount(): Promise<number> {
const [result] = await db.select({ count: sql<number>`count(*)::int` }).from(users);
return result?.count ?? 0;
}
export async function createUser(data: UserInsert): Promise<UserSelect> {
const [user] = await db.insert(users).values(data).returning();
return user!;
}
export async function updateUser(id: number, data: Partial<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
const [user] = await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, id)).returning();
return user;
}
export async function deleteUser(id: number): Promise<boolean> {
const result = await db.delete(users).where(eq(users.id, id)).returning({ id: users.id });
return result.length > 0;
}
// ── Passkeys ──
export async function getPasskeysByUserId(userId: number): Promise<PasskeySelect[]> {
return db.select().from(passkeys).where(eq(passkeys.userId, userId));
}
export async function getPasskeysByUserIdAndOrigin(userId: number, origin: string): Promise<PasskeySelect[]> {
return db.select().from(passkeys).where(and(eq(passkeys.userId, userId), eq(passkeys.origin, origin)));
}
export async function getPasskeyByCredentialId(userId: number, credentialId: string): Promise<PasskeySelect | undefined> {
const [passkey] = await db
.select()
.from(passkeys)
.where(and(eq(passkeys.userId, userId), eq(passkeys.credentialId, credentialId)));
return passkey;
}
export async function createPasskey(data: PasskeyInsert): Promise<PasskeySelect> {
const [passkey] = await db.insert(passkeys).values(data).returning();
return passkey!;
}
export async function updatePasskey(id: number, data: Partial<Omit<PasskeySelect, 'id'>>): Promise<PasskeySelect | undefined> {
const [passkey] = await db.update(passkeys).set(data).where(eq(passkeys.id, id)).returning();
return passkey;
}
// ── Passkey Challenges ──
export async function storeChallenge(userId: number, origin: string, challenge: string, ttlMs: number) {
const expiresAt = new Date(Date.now() + ttlMs);
// Upsert: delete existing challenge for this user+origin, then insert
await db.delete(passkeyChallenges).where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)));
await db.insert(passkeyChallenges).values({ userId, origin, challenge, expiresAt });
}
export async function consumeChallenge(userId: number, origin: string): Promise<string | null> {
const now = new Date();
// Clean up expired challenges
await db.delete(passkeyChallenges).where(lt(passkeyChallenges.expiresAt, now));
// Find and delete the matching challenge
const [entry] = await db
.delete(passkeyChallenges)
.where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)))
.returning();
if (!entry) return null;
if (entry.expiresAt < now) return null;
return entry.challenge;
}
// ── Token Blacklist ──
export async function blacklistToken(jti: string, expiresAt: number) {
// expiresAt comes as Unix seconds from JWT exp claim
const expiresDate = new Date(expiresAt * 1000);
await db.insert(tokenBlacklist).values({ jti, expiresAt: expiresDate }).onConflictDoNothing();
}
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
const [entry] = await db.select({ jti: tokenBlacklist.jti }).from(tokenBlacklist).where(eq(tokenBlacklist.jti, jti));
return !!entry;
}
export async function cleanupExpiredTokens() {
await db.delete(tokenBlacklist).where(lt(tokenBlacklist.expiresAt, new Date()));
}
@@ -0,0 +1,73 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { serverIntegrations, userIntegrations } from '../schema';
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
// ── Server Integrations ──
export async function getServerIntegrations(): Promise<ServerIntegrationSelect[]> {
return db.select().from(serverIntegrations);
}
export async function getServerIntegration(provider: string): Promise<ServerIntegrationSelect | undefined> {
const [row] = await db.select().from(serverIntegrations).where(eq(serverIntegrations.provider, provider));
return row;
}
export async function upsertServerIntegration(provider: string, config: Record<string, unknown>, enabled = true): Promise<ServerIntegrationSelect> {
const [row] = await db
.insert(serverIntegrations)
.values({ provider, config, enabled, updatedAt: new Date() })
.onConflictDoUpdate({
target: serverIntegrations.provider,
set: { config, enabled, updatedAt: new Date() },
})
.returning();
return row!;
}
export async function deleteServerIntegration(provider: string): Promise<boolean> {
const result = await db.delete(serverIntegrations).where(eq(serverIntegrations.provider, provider)).returning({ id: serverIntegrations.id });
return result.length > 0;
}
// ── User Integrations ──
export async function getUserIntegrations(userId: number): Promise<UserIntegrationSelect[]> {
return db.select().from(userIntegrations).where(eq(userIntegrations.userId, userId));
}
export async function getUserIntegration(userId: number, provider: string): Promise<UserIntegrationSelect | undefined> {
const [row] = await db
.select()
.from(userIntegrations)
.where(and(eq(userIntegrations.userId, userId), eq(userIntegrations.provider, provider)));
return row;
}
type UpsertUserIntegrationParams = {
userId: number;
provider: string;
serverIntegrationId?: number | null;
config: Record<string, unknown>;
};
export async function upsertUserIntegration({ userId, provider, serverIntegrationId, config }: UpsertUserIntegrationParams): Promise<UserIntegrationSelect> {
const [row] = await db
.insert(userIntegrations)
.values({ userId, provider, serverIntegrationId: serverIntegrationId ?? null, config, updatedAt: new Date() })
.onConflictDoUpdate({
target: [userIntegrations.userId, userIntegrations.provider],
set: { config, serverIntegrationId: serverIntegrationId ?? null, updatedAt: new Date() },
})
.returning();
return row!;
}
export async function deleteUserIntegration(userId: number, provider: string): Promise<boolean> {
const result = await db
.delete(userIntegrations)
.where(and(eq(userIntegrations.userId, userId), eq(userIntegrations.provider, provider)))
.returning({ id: userIntegrations.id });
return result.length > 0;
}
@@ -0,0 +1,20 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { serverConfig } from '../schema';
const SETTINGS_KEY = 'server-settings';
export async function readServerSettings(): Promise<Record<string, unknown>> {
const [row] = await db.select().from(serverConfig).where(eq(serverConfig.key, SETTINGS_KEY));
return (row?.value as Record<string, unknown>) ?? {};
}
export async function writeServerSettings(settings: Record<string, unknown>): Promise<void> {
await db
.insert(serverConfig)
.values({ key: SETTINGS_KEY, value: settings, updatedAt: new Date() })
.onConflictDoUpdate({
target: serverConfig.key,
set: { value: settings, updatedAt: new Date() },
});
}
@@ -0,0 +1,150 @@
import { pgTable, serial, text, integer, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
// ── Shared columns pattern ──
// Each table has: id, scope, userId, dirName, name, description, body, version, timestamps
// Type-specific columns are added per table
// ── Tasks ──
// Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills
export const tasks = pgTable('tasks', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
tags: jsonb('tags').$type<string[]>(),
tools: jsonb('tools').$type<string[]>(),
skills: jsonb('skills').$type<string[]>(),
inputs: jsonb('inputs'),
outputs: jsonb('outputs'),
dependencies: jsonb('dependencies'),
config: jsonb('config'),
trigger: jsonb('trigger'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tasks_scope').on(table.scope),
index('idx_tasks_user').on(table.userId),
]);
// ── Skills ──
// Minimal frontmatter: name, description only. Rich markdown body.
export const skills = pgTable('skills', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_skills_scope').on(table.scope),
index('idx_skills_user').on(table.userId),
]);
// ── Processes ──
// Same shape as skills. Represents documented workflows.
export const processes = pgTable('processes', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_processes_scope').on(table.scope),
index('idx_processes_user').on(table.userId),
]);
// ── Resources ──
// Has separate config (key-value for external service settings). No user scope.
export const resources = pgTable('resources', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
config: jsonb('config').$type<Record<string, string>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_resources_scope_dir').on(table.scope, table.dirName),
index('idx_resources_scope').on(table.scope),
]);
// ── Tools ──
// Has implementation code, language, structured input params, label.
export const tools = pgTable('tools', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
label: text('label'),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
language: text('language'),
inputs: jsonb('inputs'),
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tools_scope').on(table.scope),
index('idx_tools_user').on(table.userId),
]);
// ── Extensions ──
// Code-only, no markdown, no chat. Just implementation.
export const extensions = pgTable('extensions', {
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_extensions_scope').on(table.scope),
index('idx_extensions_user').on(table.userId),
]);
// ── Item Chats ──
// Chat history for tasks, skills, processes, resources, tools.
// Uses polymorphic reference (item_type + item_id) instead of per-table FKs.
export const itemChats = pgTable('item_chats', {
id: text('id').primaryKey(),
itemType: text('item_type').notNull(),
itemId: integer('item_id').notNull(),
messages: jsonb('messages').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
]);
@@ -0,0 +1,41 @@
import { pgTable, serial, text, integer, timestamp, index } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
password: text('password'),
role: text('role', { enum: ['Member', 'Admin', 'Owner', 'Super Admin'] }).notNull().default('Member'),
status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] }).notNull().default('Unverified'),
name: text('name'),
username: text('username').unique(),
avatar: text('avatar'),
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const passkeys = pgTable('passkeys', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
origin: text('origin'),
credentialId: text('credential_id'),
publicKey: text('public_key'),
counter: integer('counter').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const passkeyChallenges = pgTable('passkey_challenges', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
origin: text('origin').notNull(),
challenge: text('challenge').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
});
export const tokenBlacklist = pgTable('token_blacklist', {
jti: text('jti').primaryKey(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
}, (table) => [
index('idx_token_blacklist_expires').on(table.expiresAt),
]);
@@ -0,0 +1,56 @@
import { pgTable, text, integer, boolean, timestamp, jsonb, index, numeric } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const chatSessions = pgTable('chat_sessions', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
context: text('context').notNull().default('chat'),
contextId: text('context_id'),
title: text('title').notNull().default('New Chat'),
model: text('model'),
cwd: text('cwd'),
thinking: text('thinking'),
archived: boolean('archived').notNull().default(false),
groupSlug: text('group_slug'),
messageCount: integer('message_count').notNull().default(0),
costInputTokens: integer('cost_input_tokens').notNull().default(0),
costOutputTokens: integer('cost_output_tokens').notNull().default(0),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_sessions_user_context').on(table.userId, table.context),
index('idx_sessions_user_created').on(table.userId, table.createdAt),
index('idx_sessions_user_group').on(table.userId, table.groupSlug),
]);
export const chatMessages = pgTable('chat_messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull().references(() => chatSessions.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
text: text('text'),
model: text('model'),
toolName: text('tool_name'),
toolInput: jsonb('tool_input'),
toolCallId: text('tool_call_id'),
output: text('output'),
isError: boolean('is_error').default(false),
costInputTokens: integer('cost_input_tokens'),
costOutputTokens: integer('cost_output_tokens'),
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }),
sortOrder: integer('sort_order').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_messages_session_order').on(table.sessionId, table.sortOrder),
]);
export const chatGroups = pgTable('chat_groups', {
slug: text('slug').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_chat_groups_user').on(table.userId),
]);
@@ -0,0 +1,7 @@
export * from './auth';
export * from './user-data';
export * from './chat';
export * from './workspaces';
export * from './agent-items';
export * from './operations';
export * from './server';
@@ -0,0 +1,44 @@
import { pgTable, serial, text, integer, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const taskLogs = pgTable('task_logs', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskName: text('task_name').notNull(),
taskDirName: text('task_dir_name').notNull(),
entryName: text('entry_name').notNull(),
entryType: text('entry_type').notNull(),
provider: text('provider').notNull(),
model: text('model').notNull(),
isError: boolean('is_error').notNull().default(false),
messages: jsonb('messages').notNull().default([]),
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
index('idx_task_logs_user_started').on(table.userId, table.startedAt),
]);
export const queueJobs = pgTable('queue_jobs', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
lane: text('lane').notNull(),
type: text('type').notNull(),
status: text('status', { enum: ['queued', 'running', 'completed', 'failed', 'cancelled'] }).notNull().default('queued'),
currentStep: integer('current_step').notNull().default(0),
steps: jsonb('steps').notNull().default([]),
meta: jsonb('meta'),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
index('idx_queue_jobs_status_lane').on(table.status, table.lane),
index('idx_queue_jobs_user').on(table.userId),
]);
export const terminalContainers = pgTable('terminal_containers', {
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
dockerId: text('docker_id').notNull(),
port: integer('port').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,16 @@
import { pgTable, serial, boolean, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const serverConfig = pgTable('server_config', {
key: text('key').primaryKey(),
value: jsonb('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const serverIntegrations = pgTable('server_integrations', {
id: serial('id').primaryKey(),
provider: text('provider').notNull().unique(),
enabled: boolean('enabled').notNull().default(true),
config: jsonb('config').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,33 @@
import { pgTable, serial, integer, text, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { serverIntegrations } from './server';
export const userSettings = pgTable('user_settings', {
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
settings: jsonb('settings').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const userState = pgTable('user_state', {
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
state: jsonb('state').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const userIntegrations = pgTable('user_integrations', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(),
serverIntegrationId: integer('server_integration_id').references(() => serverIntegrations.id, { onDelete: 'set null' }),
config: jsonb('config').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_user_integrations_user_provider').on(table.userId, table.provider),
]);
export const dockConfigs = pgTable('dock_configs', {
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
paths: jsonb('paths').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,40 @@
import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const workspaces = pgTable('workspaces', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
hostTerminals: jsonb('host_terminals').notNull().default([]),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_workspaces_user_id').on(table.userId, table.id),
]);
export const screens = pgTable('screens', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
layout: jsonb('layout').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_screens_user_name').on(table.userId, table.name),
]);
export const projects = pgTable('projects', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
meta: jsonb('meta').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_projects_user_slug').on(table.userId, table.slug),
]);
+107 -43
View File
@@ -1,50 +1,114 @@
import type { USER_ROLES, USER_STATUSES } from 'definitions';
import type * as Schema from './schema';
// Auth
export type UserSelect = {
id: number;
email: string;
password: string | null;
role: (typeof USER_ROLES)[number] | null;
status: (typeof USER_STATUSES)[number] | null;
name: string | null;
username: string | null;
avatar: string | null;
passwordChangedAt: number | null;
};
export type UserInsert = {
email: string;
password?: string | null;
role?: (typeof USER_ROLES)[number] | null;
status?: (typeof USER_STATUSES)[number] | null;
name?: string | null;
username?: string | null;
avatar?: string | null;
passwordChangedAt?: number | null;
};
// ── Auth ──
export type UserSelect = typeof Schema.users.$inferSelect;
export type UserInsert = typeof Schema.users.$inferInsert;
export type User = UserSelect & {
passkeys: Passkey[];
};
export type PasskeySelect = {
id: number;
email: string;
origin: string | null;
credentialId: string | null;
publicKey: string | null;
counter: number;
};
export type PasskeyInsert = {
email: string;
origin?: string | null;
credentialId?: string | null;
publicKey?: string | null;
counter?: number;
passkeys: PasskeySelect[];
};
export type PasskeySelect = typeof Schema.passkeys.$inferSelect;
export type PasskeyInsert = typeof Schema.passkeys.$inferInsert;
export type Passkey = PasskeySelect & {
user: User;
user: UserSelect;
};
export type PasskeyChallengeSelect = typeof Schema.passkeyChallenges.$inferSelect;
export type PasskeyChallengeInsert = typeof Schema.passkeyChallenges.$inferInsert;
export type TokenBlacklistSelect = typeof Schema.tokenBlacklist.$inferSelect;
export type TokenBlacklistInsert = typeof Schema.tokenBlacklist.$inferInsert;
// ── User Data ──
export type UserSettingsSelect = typeof Schema.userSettings.$inferSelect;
export type UserSettingsInsert = typeof Schema.userSettings.$inferInsert;
export type UserStateSelect = typeof Schema.userState.$inferSelect;
export type UserStateInsert = typeof Schema.userState.$inferInsert;
export type UserIntegrationSelect = typeof Schema.userIntegrations.$inferSelect;
export type UserIntegrationInsert = typeof Schema.userIntegrations.$inferInsert;
export type DockConfigSelect = typeof Schema.dockConfigs.$inferSelect;
export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
// ── Chat ──
export type ChatSessionSelect = typeof Schema.chatSessions.$inferSelect;
export type ChatSessionInsert = typeof Schema.chatSessions.$inferInsert;
export type ChatSession = ChatSessionSelect & {
messages?: ChatMessageSelect[];
};
export type ChatMessageSelect = typeof Schema.chatMessages.$inferSelect;
export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert;
export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect;
export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert;
// ── Workspaces ──
export type WorkspaceSelect = typeof Schema.workspaces.$inferSelect;
export type WorkspaceInsert = typeof Schema.workspaces.$inferInsert;
export type ScreenSelect = typeof Schema.screens.$inferSelect;
export type ScreenInsert = typeof Schema.screens.$inferInsert;
export type ProjectSelect = typeof Schema.projects.$inferSelect;
export type ProjectInsert = typeof Schema.projects.$inferInsert;
// ── Tasks ──
export type TaskSelect = typeof Schema.tasks.$inferSelect;
export type TaskInsert = typeof Schema.tasks.$inferInsert;
// ── Skills ──
export type SkillSelect = typeof Schema.skills.$inferSelect;
export type SkillInsert = typeof Schema.skills.$inferInsert;
// ── Processes ──
export type ProcessSelect = typeof Schema.processes.$inferSelect;
export type ProcessInsert = typeof Schema.processes.$inferInsert;
// ── Resources ──
export type ResourceSelect = typeof Schema.resources.$inferSelect;
export type ResourceInsert = typeof Schema.resources.$inferInsert;
// ── Tools ──
export type ToolSelect = typeof Schema.tools.$inferSelect;
export type ToolInsert = typeof Schema.tools.$inferInsert;
// ── Extensions ──
export type ExtensionSelect = typeof Schema.extensions.$inferSelect;
export type ExtensionInsert = typeof Schema.extensions.$inferInsert;
// ── Item Chats ──
export type ItemChatSelect = typeof Schema.itemChats.$inferSelect;
export type ItemChatInsert = typeof Schema.itemChats.$inferInsert;
// ── Operations ──
export type TaskLogSelect = typeof Schema.taskLogs.$inferSelect;
export type TaskLogInsert = typeof Schema.taskLogs.$inferInsert;
export type QueueJobSelect = typeof Schema.queueJobs.$inferSelect;
export type QueueJobInsert = typeof Schema.queueJobs.$inferInsert;
export type TerminalContainerSelect = typeof Schema.terminalContainers.$inferSelect;
export type TerminalContainerInsert = typeof Schema.terminalContainers.$inferInsert;
// ── Server ──
export type ServerConfigSelect = typeof Schema.serverConfig.$inferSelect;
export type ServerConfigInsert = typeof Schema.serverConfig.$inferInsert;
export type ServerIntegrationSelect = typeof Schema.serverIntegrations.$inferSelect;
export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferInsert;
+2 -2
View File
@@ -50,7 +50,7 @@ const devServerWebsocket = {
try {
const payload = await verify(wsToken);
if (!payload) { ws.close(4001, 'Unauthorized'); return; }
if (payload.jti && isTokenBlacklisted(payload.jti)) { ws.close(4001, 'Unauthorized'); return; }
if (payload.jti && await isTokenBlacklisted(payload.jti)) { ws.close(4001, 'Unauthorized'); return; }
} catch {
ws.close(4001, 'Unauthorized');
return;
@@ -108,7 +108,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi')
if (!user) return new Response('Unauthorized', { status: 401 });
if (user.jti) {
if (isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
}
const url = new URL(req.url);
+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;
}
+18 -53
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
}
}
await ensureDir(filePath);
await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2));
if (Object.keys(settings).length === 0) {
await setUserSettings(userId, DEFAULT_SETTINGS);
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,
};
}
+1
View File
@@ -11,6 +11,7 @@
"@react-email/code-block": "^0.0.11",
"@react-email/components": "^0.0.31",
"@react-email/render": "^1.0.3",
"officerdb": "workspace:*",
"react-email": "^3.0.4"
}
}
+4 -10
View File
@@ -1,8 +1,7 @@
import { createTransport, type Transporter } from 'nodemailer';
import { join } from 'node:path';
import { readServerSettings } from 'officerdb';
const { MAIL_TRANSPORT, DATA_PATH } = process.env;
const dataPath = DATA_PATH ?? join(process.cwd(), 'data');
const { MAIL_TRANSPORT } = process.env;
type SmtpConfig = {
provider: 'resend' | 'smtp' | 'mailhog';
@@ -16,8 +15,6 @@ type SmtpConfig = {
fromEmail: string;
};
const settingsPath = join(dataPath, 'server-settings', 'server-settings.json');
let cachedTransport: Transporter | null = null;
let cachedConfigHash: string | null = null;
@@ -36,10 +33,8 @@ export type TransportResult = SmtpTransport | ResendTransport;
export async function getTransport(): Promise<TransportResult> {
try {
const file = Bun.file(settingsPath);
if (await file.exists()) {
const settings = await file.json();
const smtp: SmtpConfig | undefined = settings.smtp;
const settings = await readServerSettings();
const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
if (smtp) {
const from = `${smtp.fromName} <${smtp.fromEmail}>`;
@@ -56,7 +51,6 @@ export async function getTransport(): Promise<TransportResult> {
cachedConfigHash = hash;
return { type: 'smtp', transport: cachedTransport, from };
}
}
} catch {
// Fall through to env var
}