diff --git a/package.json b/package.json index 701426e3..52ca9b3e 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/scripts/migrate-auth-to-pg.ts b/scripts/migrate-auth-to-pg.ts new file mode 100644 index 00000000..accc4c03 --- /dev/null +++ b/scripts/migrate-auth-to-pg.ts @@ -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(path: string, fallback: T): Promise { + 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(join(AUTH_DIR, 'users.json'), []); + const oldPasskeys = await readJson(join(AUTH_DIR, 'passkeys.json'), []); + const oldBlacklist = await readJson(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(); + + // 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); +}); diff --git a/scripts/migrate-server-settings-to-pg.ts b/scripts/migrate-server-settings-to-pg.ts new file mode 100644 index 00000000..e7ef3f8f --- /dev/null +++ b/scripts/migrate-server-settings-to-pg.ts @@ -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; + 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); +}); diff --git a/scripts/migrate-user-settings-to-pg.ts b/scripts/migrate-user-settings-to-pg.ts new file mode 100644 index 00000000..a702613d --- /dev/null +++ b/scripts/migrate-user-settings-to-pg.ts @@ -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(path: string): Promise { + 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>(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>(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); +}); diff --git a/src/databases/officer_db/drizzle.config.ts b/src/databases/officer_db/drizzle.config.ts new file mode 100644 index 00000000..a33bbaec --- /dev/null +++ b/src/databases/officer_db/drizzle.config.ts @@ -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!, + }, +}); diff --git a/src/databases/officer_db/migrations/0000_clammy_meteorite.sql b/src/databases/officer_db/migrations/0000_clammy_meteorite.sql new file mode 100644 index 00000000..23dae652 --- /dev/null +++ b/src/databases/officer_db/migrations/0000_clammy_meteorite.sql @@ -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"); \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0000_snapshot.json b/src/databases/officer_db/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..2911c3fb --- /dev/null +++ b/src/databases/officer_db/migrations/meta/0000_snapshot.json @@ -0,0 +1,2443 @@ +{ + "id": "65f7442c-6226-471f-b6b3-d559e820ab61", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_token_blacklist_expires": { + "name": "idx_token_blacklist_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unverified'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dock_configs": { + "name": "dock_configs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "paths": { + "name": "paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dock_configs_user_id_users_id_fk": { + "name": "dock_configs_user_id_users_id_fk", + "tableFrom": "dock_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_integrations": { + "name": "user_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_integrations_user_id_users_id_fk": { + "name": "user_integrations_user_id_users_id_fk", + "tableFrom": "user_integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_user_integrations_user_provider": { + "name": "uq_user_integrations_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_state": { + "name": "user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_state_user_id_users_id_fk": { + "name": "user_state_user_id_users_id_fk", + "tableFrom": "user_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_groups": { + "name": "chat_groups", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_chat_groups_user": { + "name": "idx_chat_groups_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_groups_user_id_users_id_fk": { + "name": "chat_groups_user_id_users_id_fk", + "tableFrom": "chat_groups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_input": { + "name": "tool_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cost_input_tokens": { + "name": "cost_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_output_tokens": { + "name": "cost_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_total_usd": { + "name": "cost_total_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_messages_session_order": { + "name": "idx_messages_session_order", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Chat'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking": { + "name": "thinking", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group_slug": { + "name": "group_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_input_tokens": { + "name": "cost_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_output_tokens": { + "name": "cost_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_total_usd": { + "name": "cost_total_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_user_context": { + "name": "idx_sessions_user_context", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_user_created": { + "name": "idx_sessions_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_user_group": { + "name": "idx_sessions_user_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_user_id_users_id_fk": { + "name": "chat_sessions_user_id_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_projects_user_slug": { + "name": "uq_projects_user_slug", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.screens": { + "name": "screens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "screens_user_id_users_id_fk": { + "name": "screens_user_id_users_id_fk", + "tableFrom": "screens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_screens_user_name": { + "name": "uq_screens_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspaces_user_id_users_id_fk": { + "name": "workspaces_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_workspaces_user_id": { + "name": "uq_workspaces_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "implementation": { + "name": "implementation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_extensions_scope": { + "name": "idx_extensions_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_extensions_user": { + "name": "idx_extensions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_extensions_scope_user_dir": { + "name": "uq_extensions_scope_user_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "user_id", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_chats": { + "name": "item_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_item_chats_type_item": { + "name": "idx_item_chats_type_item", + "columns": [ + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_item_chats_type_item": { + "name": "uq_item_chats_type_item", + "nullsNotDistinct": false, + "columns": [ + "item_type", + "item_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processes": { + "name": "processes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_processes_scope": { + "name": "idx_processes_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_processes_user": { + "name": "idx_processes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processes_user_id_users_id_fk": { + "name": "processes_user_id_users_id_fk", + "tableFrom": "processes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_processes_scope_user_dir": { + "name": "uq_processes_scope_user_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "user_id", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resources_scope": { + "name": "idx_resources_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_resources_scope_dir": { + "name": "uq_resources_scope_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skills_scope": { + "name": "idx_skills_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_skills_user": { + "name": "idx_skills_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_user_id_users_id_fk": { + "name": "skills_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_skills_scope_user_dir": { + "name": "uq_skills_scope_user_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "user_id", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tools": { + "name": "tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tasks_scope": { + "name": "idx_tasks_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tasks_user": { + "name": "idx_tasks_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_users_id_fk": { + "name": "tasks_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tasks_scope_user_dir": { + "name": "uq_tasks_scope_user_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "user_id", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tools": { + "name": "tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dir_name": { + "name": "dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "implementation": { + "name": "implementation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tools_scope": { + "name": "idx_tools_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tools_user": { + "name": "idx_tools_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tools_user_id_users_id_fk": { + "name": "tools_user_id_users_id_fk", + "tableFrom": "tools", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tools_scope_user_dir": { + "name": "uq_tools_scope_user_dir", + "nullsNotDistinct": false, + "columns": [ + "scope", + "user_id", + "dir_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_jobs": { + "name": "queue_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lane": { + "name": "lane", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_queue_jobs_status_lane": { + "name": "idx_queue_jobs_status_lane", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lane", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_queue_jobs_user": { + "name": "idx_queue_jobs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_jobs_user_id_users_id_fk": { + "name": "queue_jobs_user_id_users_id_fk", + "tableFrom": "queue_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_logs": { + "name": "task_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_name": { + "name": "entry_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_task_logs_user_started": { + "name": "idx_task_logs_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_logs_user_id_users_id_fk": { + "name": "task_logs_user_id_users_id_fk", + "tableFrom": "task_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terminal_containers": { + "name": "terminal_containers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "docker_id": { + "name": "docker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "terminal_containers_user_id_users_id_fk": { + "name": "terminal_containers_user_id_users_id_fk", + "tableFrom": "terminal_containers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json new file mode 100644 index 00000000..46f5d10b --- /dev/null +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1772121115932, + "tag": "0000_clammy_meteorite", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/databases/officer_db/package.json b/src/databases/officer_db/package.json index 2a8c8d08..7937f848 100644 --- a/src/databases/officer_db/package.json +++ b/src/databases/officer_db/package.json @@ -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": { diff --git a/src/databases/officer_db/src/db.ts b/src/databases/officer_db/src/db.ts new file mode 100644 index 00000000..7bf48716 --- /dev/null +++ b/src/databases/officer_db/src/db.ts @@ -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 }); diff --git a/src/databases/officer_db/src/queries/auth.ts b/src/databases/officer_db/src/queries/auth.ts new file mode 100644 index 00000000..febf9dec --- /dev/null +++ b/src/databases/officer_db/src/queries/auth.ts @@ -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 { + return db.select().from(users); +} + +export async function getUserById(id: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, id)); + return user; +} + +export async function getUserByEmail(email: string): Promise { + const [user] = await db.select().from(users).where(eq(users.email, email)); + return user; +} + +export async function getUserCount(): Promise { + const [result] = await db.select({ count: sql`count(*)::int` }).from(users); + return result?.count ?? 0; +} + +export async function createUser(data: UserInsert): Promise { + const [user] = await db.insert(users).values(data).returning(); + return user!; +} + +export async function updateUser(id: number, data: Partial>): Promise { + 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 { + 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 { + return db.select().from(passkeys).where(eq(passkeys.userId, userId)); +} + +export async function getPasskeysByUserIdAndOrigin(userId: number, origin: string): Promise { + return db.select().from(passkeys).where(and(eq(passkeys.userId, userId), eq(passkeys.origin, origin))); +} + +export async function getPasskeyByCredentialId(userId: number, credentialId: string): Promise { + 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 { + const [passkey] = await db.insert(passkeys).values(data).returning(); + return passkey!; +} + +export async function updatePasskey(id: number, data: Partial>): Promise { + 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 { + 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 { + 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())); +} diff --git a/src/databases/officer_db/src/queries/integrations.ts b/src/databases/officer_db/src/queries/integrations.ts new file mode 100644 index 00000000..4cd9158e --- /dev/null +++ b/src/databases/officer_db/src/queries/integrations.ts @@ -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 { + return db.select().from(serverIntegrations); +} + +export async function getServerIntegration(provider: string): Promise { + const [row] = await db.select().from(serverIntegrations).where(eq(serverIntegrations.provider, provider)); + return row; +} + +export async function upsertServerIntegration(provider: string, config: Record, enabled = true): Promise { + 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 { + 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 { + return db.select().from(userIntegrations).where(eq(userIntegrations.userId, userId)); +} + +export async function getUserIntegration(userId: number, provider: string): Promise { + 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; +}; + +export async function upsertUserIntegration({ userId, provider, serverIntegrationId, config }: UpsertUserIntegrationParams): Promise { + 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 { + const result = await db + .delete(userIntegrations) + .where(and(eq(userIntegrations.userId, userId), eq(userIntegrations.provider, provider))) + .returning({ id: userIntegrations.id }); + return result.length > 0; +} diff --git a/src/databases/officer_db/src/queries/server-config.ts b/src/databases/officer_db/src/queries/server-config.ts new file mode 100644 index 00000000..a4a42207 --- /dev/null +++ b/src/databases/officer_db/src/queries/server-config.ts @@ -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> { + const [row] = await db.select().from(serverConfig).where(eq(serverConfig.key, SETTINGS_KEY)); + return (row?.value as Record) ?? {}; +} + +export async function writeServerSettings(settings: Record): Promise { + await db + .insert(serverConfig) + .values({ key: SETTINGS_KEY, value: settings, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: serverConfig.key, + set: { value: settings, updatedAt: new Date() }, + }); +} diff --git a/src/databases/officer_db/src/schema/agent-items.ts b/src/databases/officer_db/src/schema/agent-items.ts new file mode 100644 index 00000000..791f6fa1 --- /dev/null +++ b/src/databases/officer_db/src/schema/agent-items.ts @@ -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(), + tools: jsonb('tools').$type(), + skills: jsonb('skills').$type(), + 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>().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), +]); diff --git a/src/databases/officer_db/src/schema/auth.ts b/src/databases/officer_db/src/schema/auth.ts new file mode 100644 index 00000000..6764f22e --- /dev/null +++ b/src/databases/officer_db/src/schema/auth.ts @@ -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), +]); diff --git a/src/databases/officer_db/src/schema/chat.ts b/src/databases/officer_db/src/schema/chat.ts new file mode 100644 index 00000000..33e2940a --- /dev/null +++ b/src/databases/officer_db/src/schema/chat.ts @@ -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), +]); diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts new file mode 100644 index 00000000..261d4a46 --- /dev/null +++ b/src/databases/officer_db/src/schema/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/schema/operations.ts b/src/databases/officer_db/src/schema/operations.ts new file mode 100644 index 00000000..c4f865f9 --- /dev/null +++ b/src/databases/officer_db/src/schema/operations.ts @@ -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(), +}); diff --git a/src/databases/officer_db/src/schema/server.ts b/src/databases/officer_db/src/schema/server.ts new file mode 100644 index 00000000..57f721db --- /dev/null +++ b/src/databases/officer_db/src/schema/server.ts @@ -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(), +}); diff --git a/src/databases/officer_db/src/schema/user-data.ts b/src/databases/officer_db/src/schema/user-data.ts new file mode 100644 index 00000000..9617e3fd --- /dev/null +++ b/src/databases/officer_db/src/schema/user-data.ts @@ -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(), +}); diff --git a/src/databases/officer_db/src/schema/workspaces.ts b/src/databases/officer_db/src/schema/workspaces.ts new file mode 100644 index 00000000..b30cb141 --- /dev/null +++ b/src/databases/officer_db/src/schema/workspaces.ts @@ -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), +]); diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index bc9761a9..b29cda53 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -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; diff --git a/src/server.tsx b/src/server.tsx index b73dcd6c..9fb9f8e8 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -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); diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index 464ab786..fb05acc0 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -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(); } } diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index 11467c02..2a122943 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -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) { diff --git a/src/servers/api/auth/change-password.ts b/src/servers/api/auth/change-password.ts index 5f29c2e9..d4dd9c64 100644 --- a/src/servers/api/auth/change-password.ts +++ b/src/servers/api/auth/change-password.ts @@ -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; diff --git a/src/servers/api/auth/forgot-password.ts b/src/servers/api/auth/forgot-password.ts index 79c8d54e..aea47fef 100644 --- a/src/servers/api/auth/forgot-password.ts +++ b/src/servers/api/auth/forgot-password.ts @@ -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'); diff --git a/src/servers/api/auth/passkey-router.ts b/src/servers/api/auth/passkey-router.ts index 941f8770..44d161bf 100644 --- a/src/servers/api/auth/passkey-router.ts +++ b/src/servers/api/auth/passkey-router.ts @@ -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({ diff --git a/src/servers/api/auth/resend-verification.ts b/src/servers/api/auth/resend-verification.ts index c8bee898..65b2a2c4 100644 --- a/src/servers/api/auth/resend-verification.ts +++ b/src/servers/api/auth/resend-verification.ts @@ -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'); diff --git a/src/servers/api/auth/reset-password.ts b/src/servers/api/auth/reset-password.ts index 65b2bba8..d0a819c3 100644 --- a/src/servers/api/auth/reset-password.ts +++ b/src/servers/api/auth/reset-password.ts @@ -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 }); }; diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index 084f7fb0..1bb0fdaa 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -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)); diff --git a/src/servers/api/auth/signup.ts b/src/servers/api/auth/signup.ts index 4207ab9e..28d89e61 100644 --- a/src/servers/api/auth/signup.ts +++ b/src/servers/api/auth/signup.ts @@ -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({ diff --git a/src/servers/api/auth/users-me.ts b/src/servers/api/auth/users-me.ts index e93a479c..9a932f2f 100644 --- a/src/servers/api/auth/users-me.ts +++ b/src/servers/api/auth/users-me.ts @@ -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 }; diff --git a/src/servers/api/auth/verify-token.ts b/src/servers/api/auth/verify-token.ts index 7f16fbc9..2887249a 100644 --- a/src/servers/api/auth/verify-token.ts +++ b/src/servers/api/auth/verify-token.ts @@ -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 diff --git a/src/servers/api/auth/verify.ts b/src/servers/api/auth/verify.ts index 889af665..3afd6334 100644 --- a/src/servers/api/auth/verify.ts +++ b/src/servers/api/auth/verify.ts @@ -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 = { 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 diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts index e7a697e8..8e565cfe 100644 --- a/src/servers/api/dev-server/router.ts +++ b/src/servers/api/dev-server/router.ts @@ -267,7 +267,7 @@ async function validateJwt(req: Request): Promise { 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; diff --git a/src/servers/api/landing-page-data/landing-page-data.ts b/src/servers/api/landing-page-data/landing-page-data.ts index 0cd105cd..edf755d7 100644 --- a/src/servers/api/landing-page-data/landing-page-data.ts +++ b/src/servers/api/landing-page-data/landing-page-data.ts @@ -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 }); }); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 8f63aa61..7eb3da51 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -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 { + 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 { + 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', { diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 40885f40..98149754 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -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 { +async function getUserDefaultModel(userId: number): Promise { 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 | 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) }); diff --git a/src/servers/api/server-settings/ocr.ts b/src/servers/api/server-settings/ocr.ts index 94246c5b..644c5602 100644 --- a/src/servers/api/server-settings/ocr.ts +++ b/src/servers/api/server-settings/ocr.ts @@ -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 { 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(); - 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 }); }); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 03251621..6ee22086 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -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 = settings.plugins ?? {}; + const settings = await readServerSettings(); + const pluginSettings: Record = (settings.plugins as Record) ?? {}; const plugins: { id: string; name: string; description: string; enabled: boolean }[] = []; diff --git a/src/servers/api/server-settings/smtp.ts b/src/servers/api/server-settings/smtp.ts index c63173a8..f4ed0237 100644 --- a/src/servers/api/server-settings/smtp.ts +++ b/src/servers/api/server-settings/smtp.ts @@ -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(); - 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; diff --git a/src/servers/api/server-settings/stt.ts b/src/servers/api/server-settings/stt.ts index ae73fcaf..603f14c1 100644 --- a/src/servers/api/server-settings/stt.ts +++ b/src/servers/api/server-settings/stt.ts @@ -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 { 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(); - 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 }); }); diff --git a/src/servers/api/server-settings/sync-user-pi-config.ts b/src/servers/api/server-settings/sync-user-pi-config.ts index f5bdfd91..36090b22 100644 --- a/src/servers/api/server-settings/sync-user-pi-config.ts +++ b/src/servers/api/server-settings/sync-user-pi-config.ts @@ -143,7 +143,7 @@ export async function syncUserPiConfig(email: string): Promise { } export async function syncAllUserPiConfigs(): Promise { - const users = getUsers(); + const users = await getUsers(); if (users.length === 0) return; const [appConfig, policy, apiKeys] = await Promise.all([ diff --git a/src/servers/api/server-settings/tts.ts b/src/servers/api/server-settings/tts.ts index c1f877d9..a7db4818 100644 --- a/src/servers/api/server-settings/tts.ts +++ b/src/servers/api/server-settings/tts.ts @@ -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 { 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(); - 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(); - 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; } diff --git a/src/servers/api/settings/settings.ts b/src/servers/api/settings/settings.ts index e65e867c..9cf64da2 100644 --- a/src/servers/api/settings/settings.ts +++ b/src/servers/api/settings/settings.ts @@ -1,11 +1,9 @@ import { createRouter } from '../../create-router'; -import { mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { getUserSettingsFile, getUserStateFile } from '@@/data-path'; +import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb'; const DEFAULT_SETTINGS = { chat: { - defaultProvider: 'claude', + defaultProvider: 'pi', defaultModel: null, systemPrompt: '', temperature: 1, @@ -16,73 +14,40 @@ const DEFAULT_SETTINGS = { }, }; -const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true }); - export const settingsRouter = createRouter(); -// GET /settings — return settings.json, auto-create with defaults if missing +// GET /settings — return user settings from DB, default if empty settingsRouter.get('/settings', async (ctx) => { - const email = ctx.get('user').email; - const filePath = getUserSettingsFile(email); - const file = Bun.file(filePath); + const userId = ctx.get('user').id; + const settings = await getUserSettings(userId); - if (await file.exists()) { - try { - return ctx.json(await file.json()); - } catch { - // corrupted — fall through to defaults - } + if (Object.keys(settings).length === 0) { + await setUserSettings(userId, DEFAULT_SETTINGS); + return ctx.json(DEFAULT_SETTINGS); } - await ensureDir(filePath); - await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2)); - return ctx.json(DEFAULT_SETTINGS); + return ctx.json(settings); }); // PUT /settings — full replacement settingsRouter.put('/settings', async (ctx) => { - const email = ctx.get('user').email; + const userId = ctx.get('user').id; const body = ctx.get('body'); - const filePath = getUserSettingsFile(email); - - await ensureDir(filePath); - await Bun.write(filePath, JSON.stringify(body, null, 2)); + await setUserSettings(userId, body); return ctx.json(body); }); -// GET /state — return state.json, auto-create with {} if missing +// GET /state — return user state from DB settingsRouter.get('/state', async (ctx) => { - const email = ctx.get('user').email; - const filePath = getUserStateFile(email); - const file = Bun.file(filePath); - - if (await file.exists()) { - try { - return ctx.json(await file.json()); - } catch { - // corrupted — fall through to empty - } - } - - await ensureDir(filePath); - await Bun.write(file, JSON.stringify({}, null, 2)); - return ctx.json({}); + const userId = ctx.get('user').id; + const state = await getUserState(userId); + return ctx.json(state); }); // PATCH /state — shallow-merge incoming keys settingsRouter.patch('/state', async (ctx) => { - const email = ctx.get('user').email; + const userId = ctx.get('user').id; const body = ctx.get('body'); - const filePath = getUserStateFile(email); - const file = Bun.file(filePath); - - let existing: Record = {}; - 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); }); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 7af0ab5a..70e52996 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -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 }); diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index 287c98a3..972c50d1 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -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); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index b3b2ab1e..b0e9e7d9 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -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 { 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 => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index fa0be19c..62cf90a0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 }); }); diff --git a/src/servers/migrate-resources.ts b/src/servers/migrate-resources.ts index e503a9dd..f1737dbc 100644 --- a/src/servers/migrate-resources.ts +++ b/src/servers/migrate-resources.ts @@ -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 = { stt: 'speech-to-text', @@ -9,10 +9,10 @@ const SETTINGS_TO_RESOURCE: Record = { ocr: 'optical-character-recognition', }; -export function migrateSettingsToResources(): void { - let settings: Record> = {}; +export async function migrateSettingsToResources(): Promise { + let settings: Record>; try { - settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + settings = (await readServerSettings()) as Record>; } catch { return; } diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index 522daa11..2d10651c 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -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 { - const config = await Bun.file(googleConfigPath).json().catch(() => null); - if (!config?.clientId || !config?.clientSecret) { +async function loadCredentials(email: string): Promise { + const serverGoogle = await getServerIntegration('google'); + const serverConfig = serverGoogle?.config as Record | 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 | 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, }; } diff --git a/src/workspaces/emailer/package.json b/src/workspaces/emailer/package.json index 90f43475..3db135a5 100644 --- a/src/workspaces/emailer/package.json +++ b/src/workspaces/emailer/package.json @@ -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" } } diff --git a/src/workspaces/emailer/src/transport.ts b/src/workspaces/emailer/src/transport.ts index d41423f6..04e2d571 100644 --- a/src/workspaces/emailer/src/transport.ts +++ b/src/workspaces/emailer/src/transport.ts @@ -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,26 +33,23 @@ export type TransportResult = SmtpTransport | ResendTransport; export async function getTransport(): Promise { try { - const file = Bun.file(settingsPath); - if (await file.exists()) { - const settings = await file.json(); - const smtp: SmtpConfig | undefined = settings.smtp; - if (smtp) { - const from = `${smtp.fromName} <${smtp.fromEmail}>`; + const settings = await readServerSettings(); + const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined; + if (smtp) { + const from = `${smtp.fromName} <${smtp.fromEmail}>`; - if (smtp.provider === 'resend') { - return { type: 'resend', apiKey: smtp.apiKey ?? '', from }; - } + if (smtp.provider === 'resend') { + return { type: 'resend', apiKey: smtp.apiKey ?? '', from }; + } - const hash = JSON.stringify(smtp); - if (cachedTransport && cachedConfigHash === hash) { - return { type: 'smtp', transport: cachedTransport, from }; - } - const url = buildTransportUrl(smtp); - cachedTransport = createTransport(url); - cachedConfigHash = hash; + const hash = JSON.stringify(smtp); + if (cachedTransport && cachedConfigHash === hash) { return { type: 'smtp', transport: cachedTransport, from }; } + const url = buildTransportUrl(smtp); + cachedTransport = createTransport(url); + cachedConfigHash = hash; + return { type: 'smtp', transport: cachedTransport, from }; } } catch { // Fall through to env var