migration to postgres
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "cd src/databases/officer_db && bun run push",
|
"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",
|
"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": "{ 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}\"",
|
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Migration script: auth data from JSON files → PostgreSQL
|
||||||
|
*
|
||||||
|
* Migrates:
|
||||||
|
* - users.json → users table
|
||||||
|
* - passkeys.json → passkeys table (email → userId FK)
|
||||||
|
* - token-blacklist.json → token_blacklist table
|
||||||
|
*
|
||||||
|
* Usage: bun run scripts/migrate-auth-to-pg.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { users, passkeys, tokenBlacklist } from 'officerdb/schema';
|
||||||
|
|
||||||
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
|
const AUTH_DIR = join(DATA_PATH, 'auth');
|
||||||
|
|
||||||
|
type OldUser = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
password: string | null;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
name: string | null;
|
||||||
|
username: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
passwordChangedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OldPasskey = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
origin: string | null;
|
||||||
|
credentialId: string | null;
|
||||||
|
publicKey: string | null;
|
||||||
|
counter: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OldBlacklistEntry = {
|
||||||
|
jti: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readJson<T>(path: string, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (!(await file.exists())) return fallback;
|
||||||
|
return (await file.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log(`[migrate] Reading JSON files from ${AUTH_DIR}`);
|
||||||
|
|
||||||
|
const oldUsers = await readJson<OldUser[]>(join(AUTH_DIR, 'users.json'), []);
|
||||||
|
const oldPasskeys = await readJson<OldPasskey[]>(join(AUTH_DIR, 'passkeys.json'), []);
|
||||||
|
const oldBlacklist = await readJson<OldBlacklistEntry[]>(join(AUTH_DIR, 'token-blacklist.json'), []);
|
||||||
|
|
||||||
|
console.log(`[migrate] Found: ${oldUsers.length} users, ${oldPasskeys.length} passkeys, ${oldBlacklist.length} blacklisted tokens`);
|
||||||
|
|
||||||
|
if (oldUsers.length === 0) {
|
||||||
|
console.log('[migrate] No users to migrate. Done.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email → userId map for passkey migration
|
||||||
|
const emailToUserId = new Map<string, number>();
|
||||||
|
|
||||||
|
// Migrate users
|
||||||
|
console.log('[migrate] Migrating users...');
|
||||||
|
for (const u of oldUsers) {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
email: u.email,
|
||||||
|
password: u.password,
|
||||||
|
role: u.role as 'Member' | 'Admin' | 'Owner' | 'Super Admin',
|
||||||
|
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
|
||||||
|
name: u.name,
|
||||||
|
username: u.username,
|
||||||
|
avatar: u.avatar,
|
||||||
|
passwordChangedAt: u.passwordChangedAt ? new Date(u.passwordChangedAt) : null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
emailToUserId.set(u.email, inserted!.id);
|
||||||
|
console.log(` [user] ${u.email} (old id=${u.id} → new id=${inserted!.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate passkeys
|
||||||
|
if (oldPasskeys.length > 0) {
|
||||||
|
console.log('[migrate] Migrating passkeys...');
|
||||||
|
for (const p of oldPasskeys) {
|
||||||
|
const userId = emailToUserId.get(p.email);
|
||||||
|
if (!userId) {
|
||||||
|
console.warn(` [passkey] Skipping passkey for unknown email: ${p.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(passkeys).values({
|
||||||
|
userId,
|
||||||
|
origin: p.origin,
|
||||||
|
credentialId: p.credentialId,
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
counter: p.counter,
|
||||||
|
});
|
||||||
|
console.log(` [passkey] ${p.email} / ${p.origin}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate token blacklist
|
||||||
|
if (oldBlacklist.length > 0) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const active = oldBlacklist.filter((b) => b.expiresAt >= now);
|
||||||
|
console.log(`[migrate] Migrating ${active.length} active blacklisted tokens (${oldBlacklist.length - active.length} expired, skipped)...`);
|
||||||
|
|
||||||
|
for (const b of active) {
|
||||||
|
await db
|
||||||
|
.insert(tokenBlacklist)
|
||||||
|
.values({
|
||||||
|
jti: b.jti,
|
||||||
|
expiresAt: new Date(b.expiresAt * 1000),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[migrate] Done!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('[migrate] Failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Migration script: server-settings.json → PostgreSQL server_config table
|
||||||
|
*
|
||||||
|
* Usage: bun run scripts/migrate-server-settings-to-pg.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { writeServerSettings } from 'officerdb';
|
||||||
|
|
||||||
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
|
const settingsPath = join(DATA_PATH, 'server-settings', 'server-settings.json');
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log(`[migrate] Reading ${settingsPath}`);
|
||||||
|
|
||||||
|
const file = Bun.file(settingsPath);
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
console.log('[migrate] No server-settings.json found. Done.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
settings = await file.json();
|
||||||
|
} catch {
|
||||||
|
console.log('[migrate] Could not parse server-settings.json. Done.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(settings);
|
||||||
|
console.log(`[migrate] Found ${keys.length} keys: ${keys.join(', ')}`);
|
||||||
|
|
||||||
|
await writeServerSettings(settings);
|
||||||
|
console.log('[migrate] Written to server_config table.');
|
||||||
|
console.log('[migrate] Done!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('[migrate] Failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Migration script: per-user settings.json and state.json → PostgreSQL
|
||||||
|
*
|
||||||
|
* Reads from $DATA_PATH/{email}/settings/settings.json and state/state.json
|
||||||
|
* Writes to user_settings and user_state tables
|
||||||
|
*
|
||||||
|
* Usage: bun run scripts/migrate-user-settings-to-pg.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { getUserByEmail, setUserSettings, patchUserState } from 'officerdb';
|
||||||
|
|
||||||
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
|
|
||||||
|
async function readJson<T>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
return (await file.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log(`[migrate] Scanning ${DATA_PATH} for user data dirs`);
|
||||||
|
|
||||||
|
// User data dirs are named by email (contain @)
|
||||||
|
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
|
||||||
|
const userDirs = entries.filter((e) => e.isDirectory() && e.name.includes('@'));
|
||||||
|
|
||||||
|
console.log(`[migrate] Found ${userDirs.length} user dirs: ${userDirs.map((d) => d.name).join(', ')}`);
|
||||||
|
|
||||||
|
for (const dir of userDirs) {
|
||||||
|
const email = dir.name;
|
||||||
|
const dbUser = await getUserByEmail(email);
|
||||||
|
if (!dbUser) {
|
||||||
|
console.warn(` [skip] ${email} — no matching user in DB`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const settingsPath = join(DATA_PATH, email, 'settings', 'settings.json');
|
||||||
|
const settings = await readJson<Record<string, unknown>>(settingsPath);
|
||||||
|
if (settings && Object.keys(settings).length > 0) {
|
||||||
|
await setUserSettings(dbUser.id, settings);
|
||||||
|
console.log(` [settings] ${email} — ${Object.keys(settings).length} keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
const statePath = join(DATA_PATH, email, 'state', 'state.json');
|
||||||
|
const state = await readJson<Record<string, unknown>>(statePath);
|
||||||
|
if (state && Object.keys(state).length > 0) {
|
||||||
|
await patchUserState(dbUser.id, state);
|
||||||
|
console.log(` [state] ${email} — ${Object.keys(state).length} keys`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[migrate] Done!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('[migrate] Failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
const envPath = resolve(__dirname, '../../../.env');
|
||||||
|
try {
|
||||||
|
const text = readFileSync(envPath, 'utf-8');
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const match = line.match(/^(\w+)=(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, key, val] = match;
|
||||||
|
if (!process.env[key!]) process.env[key!] = val!.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/schema/index.ts',
|
||||||
|
out: './migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.POSTGRES_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
CREATE TABLE "passkey_challenges" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"origin" text NOT NULL,
|
||||||
|
"challenge" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "passkeys" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"origin" text,
|
||||||
|
"credential_id" text,
|
||||||
|
"public_key" text,
|
||||||
|
"counter" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "token_blacklist" (
|
||||||
|
"jti" text PRIMARY KEY NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"password" text,
|
||||||
|
"role" text DEFAULT 'Member' NOT NULL,
|
||||||
|
"status" text DEFAULT 'Unverified' NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"username" text,
|
||||||
|
"avatar" text,
|
||||||
|
"password_changed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email"),
|
||||||
|
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dock_configs" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_integrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"profile" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_settings" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_state" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "chat_groups" (
|
||||||
|
"slug" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "chat_messages" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"role" text NOT NULL,
|
||||||
|
"text" text,
|
||||||
|
"model" text,
|
||||||
|
"tool_name" text,
|
||||||
|
"tool_input" jsonb,
|
||||||
|
"tool_call_id" text,
|
||||||
|
"output" text,
|
||||||
|
"is_error" boolean DEFAULT false,
|
||||||
|
"cost_input_tokens" integer,
|
||||||
|
"cost_output_tokens" integer,
|
||||||
|
"cost_total_usd" numeric(12, 6),
|
||||||
|
"sort_order" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "chat_sessions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"context" text DEFAULT 'chat' NOT NULL,
|
||||||
|
"context_id" text,
|
||||||
|
"title" text DEFAULT 'New Chat' NOT NULL,
|
||||||
|
"model" text,
|
||||||
|
"cwd" text,
|
||||||
|
"thinking" text,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"group_slug" text,
|
||||||
|
"message_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"cost_input_tokens" integer DEFAULT 0 NOT NULL,
|
||||||
|
"cost_output_tokens" integer DEFAULT 0 NOT NULL,
|
||||||
|
"cost_total_usd" numeric(12, 6) DEFAULT '0' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "projects" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "screens" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "workspaces" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"host_terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_workspaces_user_id" UNIQUE("user_id","id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "extensions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"implementation" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_extensions_scope_user_dir" UNIQUE("scope","user_id","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "item_chats" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"item_type" text NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_item_chats_type_item" UNIQUE("item_type","item_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "processes" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"body" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_processes_scope_user_dir" UNIQUE("scope","user_id","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "resources" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"body" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_resources_scope_dir" UNIQUE("scope","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "skills" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"body" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_skills_scope_user_dir" UNIQUE("scope","user_id","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"body" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"tags" jsonb,
|
||||||
|
"tools" jsonb,
|
||||||
|
"skills" jsonb,
|
||||||
|
"inputs" jsonb,
|
||||||
|
"outputs" jsonb,
|
||||||
|
"dependencies" jsonb,
|
||||||
|
"config" jsonb,
|
||||||
|
"trigger" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_tasks_scope_user_dir" UNIQUE("scope","user_id","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tools" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"scope" text NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"dir_name" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"label" text,
|
||||||
|
"description" text,
|
||||||
|
"body" text,
|
||||||
|
"version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"language" text,
|
||||||
|
"inputs" jsonb,
|
||||||
|
"implementation" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_tools_scope_user_dir" UNIQUE("scope","user_id","dir_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "queue_jobs" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"lane" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'queued' NOT NULL,
|
||||||
|
"current_step" integer DEFAULT 0 NOT NULL,
|
||||||
|
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"meta" jsonb,
|
||||||
|
"error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "task_logs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"task_name" text NOT NULL,
|
||||||
|
"task_dir_name" text NOT NULL,
|
||||||
|
"entry_name" text NOT NULL,
|
||||||
|
"entry_type" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"is_error" boolean DEFAULT false NOT NULL,
|
||||||
|
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"started_at" timestamp with time zone NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "terminal_containers" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"docker_id" text NOT NULL,
|
||||||
|
"port" integer NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "server_config" (
|
||||||
|
"key" text PRIMARY KEY NOT NULL,
|
||||||
|
"value" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "processes" ADD CONSTRAINT "processes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "skills" ADD CONSTRAINT "skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tools" ADD CONSTRAINT "tools_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_chat_groups_user" ON "chat_groups" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_messages_session_order" ON "chat_messages" USING btree ("session_id","sort_order");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_sessions_user_context" ON "chat_sessions" USING btree ("user_id","context");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_sessions_user_created" ON "chat_sessions" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_sessions_user_group" ON "chat_sessions" USING btree ("user_id","group_slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_extensions_scope" ON "extensions" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_extensions_user" ON "extensions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_item_chats_type_item" ON "item_chats" USING btree ("item_type","item_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_processes_scope" ON "processes" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_processes_user" ON "processes" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_resources_scope" ON "resources" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_skills_scope" ON "skills" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_skills_user" ON "skills" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_tasks_scope" ON "tasks" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_tasks_user" ON "tasks" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_tools_scope" ON "tools" USING btree ("scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_tools_user" ON "tools" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772121115932,
|
||||||
|
"tag": "0000_clammy_meteorite",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,7 +5,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const { POSTGRES_URL } = process.env;
|
||||||
|
|
||||||
|
if (!POSTGRES_URL) {
|
||||||
|
throw new Error('POSTGRES_URL environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = postgres(POSTGRES_URL);
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { eq, and, lt, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { users, passkeys, passkeyChallenges, tokenBlacklist } from '../schema';
|
||||||
|
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
|
||||||
|
|
||||||
|
// ── Users ──
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<UserSelect[]> {
|
||||||
|
return db.select().from(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: number): Promise<UserSelect | undefined> {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string): Promise<UserSelect | undefined> {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCount(): Promise<number> {
|
||||||
|
const [result] = await db.select({ count: sql<number>`count(*)::int` }).from(users);
|
||||||
|
return result?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: UserInsert): Promise<UserSelect> {
|
||||||
|
const [user] = await db.insert(users).values(data).returning();
|
||||||
|
return user!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, data: Partial<Omit<UserSelect, 'id'>>): Promise<UserSelect | undefined> {
|
||||||
|
const [user] = await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, id)).returning();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<boolean> {
|
||||||
|
const result = await db.delete(users).where(eq(users.id, id)).returning({ id: users.id });
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passkeys ──
|
||||||
|
|
||||||
|
export async function getPasskeysByUserId(userId: number): Promise<PasskeySelect[]> {
|
||||||
|
return db.select().from(passkeys).where(eq(passkeys.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPasskeysByUserIdAndOrigin(userId: number, origin: string): Promise<PasskeySelect[]> {
|
||||||
|
return db.select().from(passkeys).where(and(eq(passkeys.userId, userId), eq(passkeys.origin, origin)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPasskeyByCredentialId(userId: number, credentialId: string): Promise<PasskeySelect | undefined> {
|
||||||
|
const [passkey] = await db
|
||||||
|
.select()
|
||||||
|
.from(passkeys)
|
||||||
|
.where(and(eq(passkeys.userId, userId), eq(passkeys.credentialId, credentialId)));
|
||||||
|
return passkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPasskey(data: PasskeyInsert): Promise<PasskeySelect> {
|
||||||
|
const [passkey] = await db.insert(passkeys).values(data).returning();
|
||||||
|
return passkey!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePasskey(id: number, data: Partial<Omit<PasskeySelect, 'id'>>): Promise<PasskeySelect | undefined> {
|
||||||
|
const [passkey] = await db.update(passkeys).set(data).where(eq(passkeys.id, id)).returning();
|
||||||
|
return passkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passkey Challenges ──
|
||||||
|
|
||||||
|
export async function storeChallenge(userId: number, origin: string, challenge: string, ttlMs: number) {
|
||||||
|
const expiresAt = new Date(Date.now() + ttlMs);
|
||||||
|
|
||||||
|
// Upsert: delete existing challenge for this user+origin, then insert
|
||||||
|
await db.delete(passkeyChallenges).where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)));
|
||||||
|
await db.insert(passkeyChallenges).values({ userId, origin, challenge, expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeChallenge(userId: number, origin: string): Promise<string | null> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Clean up expired challenges
|
||||||
|
await db.delete(passkeyChallenges).where(lt(passkeyChallenges.expiresAt, now));
|
||||||
|
|
||||||
|
// Find and delete the matching challenge
|
||||||
|
const [entry] = await db
|
||||||
|
.delete(passkeyChallenges)
|
||||||
|
.where(and(eq(passkeyChallenges.userId, userId), eq(passkeyChallenges.origin, origin)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.expiresAt < now) return null;
|
||||||
|
return entry.challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Blacklist ──
|
||||||
|
|
||||||
|
export async function blacklistToken(jti: string, expiresAt: number) {
|
||||||
|
// expiresAt comes as Unix seconds from JWT exp claim
|
||||||
|
const expiresDate = new Date(expiresAt * 1000);
|
||||||
|
await db.insert(tokenBlacklist).values({ jti, expiresAt: expiresDate }).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
|
||||||
|
const [entry] = await db.select({ jti: tokenBlacklist.jti }).from(tokenBlacklist).where(eq(tokenBlacklist.jti, jti));
|
||||||
|
return !!entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupExpiredTokens() {
|
||||||
|
await db.delete(tokenBlacklist).where(lt(tokenBlacklist.expiresAt, new Date()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { serverIntegrations, userIntegrations } from '../schema';
|
||||||
|
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
|
||||||
|
|
||||||
|
// ── Server Integrations ──
|
||||||
|
|
||||||
|
export async function getServerIntegrations(): Promise<ServerIntegrationSelect[]> {
|
||||||
|
return db.select().from(serverIntegrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerIntegration(provider: string): Promise<ServerIntegrationSelect | undefined> {
|
||||||
|
const [row] = await db.select().from(serverIntegrations).where(eq(serverIntegrations.provider, provider));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertServerIntegration(provider: string, config: Record<string, unknown>, enabled = true): Promise<ServerIntegrationSelect> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(serverIntegrations)
|
||||||
|
.values({ provider, config, enabled, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: serverIntegrations.provider,
|
||||||
|
set: { config, enabled, updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServerIntegration(provider: string): Promise<boolean> {
|
||||||
|
const result = await db.delete(serverIntegrations).where(eq(serverIntegrations.provider, provider)).returning({ id: serverIntegrations.id });
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Integrations ──
|
||||||
|
|
||||||
|
export async function getUserIntegrations(userId: number): Promise<UserIntegrationSelect[]> {
|
||||||
|
return db.select().from(userIntegrations).where(eq(userIntegrations.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserIntegration(userId: number, provider: string): Promise<UserIntegrationSelect | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(userIntegrations)
|
||||||
|
.where(and(eq(userIntegrations.userId, userId), eq(userIntegrations.provider, provider)));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertUserIntegrationParams = {
|
||||||
|
userId: number;
|
||||||
|
provider: string;
|
||||||
|
serverIntegrationId?: number | null;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function upsertUserIntegration({ userId, provider, serverIntegrationId, config }: UpsertUserIntegrationParams): Promise<UserIntegrationSelect> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(userIntegrations)
|
||||||
|
.values({ userId, provider, serverIntegrationId: serverIntegrationId ?? null, config, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userIntegrations.userId, userIntegrations.provider],
|
||||||
|
set: { config, serverIntegrationId: serverIntegrationId ?? null, updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserIntegration(userId: number, provider: string): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.delete(userIntegrations)
|
||||||
|
.where(and(eq(userIntegrations.userId, userId), eq(userIntegrations.provider, provider)))
|
||||||
|
.returning({ id: userIntegrations.id });
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { serverConfig } from '../schema';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'server-settings';
|
||||||
|
|
||||||
|
export async function readServerSettings(): Promise<Record<string, unknown>> {
|
||||||
|
const [row] = await db.select().from(serverConfig).where(eq(serverConfig.key, SETTINGS_KEY));
|
||||||
|
return (row?.value as Record<string, unknown>) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeServerSettings(settings: Record<string, unknown>): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(serverConfig)
|
||||||
|
.values({ key: SETTINGS_KEY, value: settings, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: serverConfig.key,
|
||||||
|
set: { value: settings, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { pgTable, serial, text, integer, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
// ── Shared columns pattern ──
|
||||||
|
// Each table has: id, scope, userId, dirName, name, description, body, version, timestamps
|
||||||
|
// Type-specific columns are added per table
|
||||||
|
|
||||||
|
// ── Tasks ──
|
||||||
|
// Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills
|
||||||
|
|
||||||
|
export const tasks = pgTable('tasks', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
body: text('body'),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
tags: jsonb('tags').$type<string[]>(),
|
||||||
|
tools: jsonb('tools').$type<string[]>(),
|
||||||
|
skills: jsonb('skills').$type<string[]>(),
|
||||||
|
inputs: jsonb('inputs'),
|
||||||
|
outputs: jsonb('outputs'),
|
||||||
|
dependencies: jsonb('dependencies'),
|
||||||
|
config: jsonb('config'),
|
||||||
|
trigger: jsonb('trigger'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName),
|
||||||
|
index('idx_tasks_scope').on(table.scope),
|
||||||
|
index('idx_tasks_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Skills ──
|
||||||
|
// Minimal frontmatter: name, description only. Rich markdown body.
|
||||||
|
|
||||||
|
export const skills = pgTable('skills', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
body: text('body'),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName),
|
||||||
|
index('idx_skills_scope').on(table.scope),
|
||||||
|
index('idx_skills_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Processes ──
|
||||||
|
// Same shape as skills. Represents documented workflows.
|
||||||
|
|
||||||
|
export const processes = pgTable('processes', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
body: text('body'),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName),
|
||||||
|
index('idx_processes_scope').on(table.scope),
|
||||||
|
index('idx_processes_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Resources ──
|
||||||
|
// Has separate config (key-value for external service settings). No user scope.
|
||||||
|
|
||||||
|
export const resources = pgTable('resources', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
body: text('body'),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
config: jsonb('config').$type<Record<string, string>>().notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_resources_scope_dir').on(table.scope, table.dirName),
|
||||||
|
index('idx_resources_scope').on(table.scope),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Tools ──
|
||||||
|
// Has implementation code, language, structured input params, label.
|
||||||
|
|
||||||
|
export const tools = pgTable('tools', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
label: text('label'),
|
||||||
|
description: text('description'),
|
||||||
|
body: text('body'),
|
||||||
|
version: integer('version').notNull().default(1),
|
||||||
|
language: text('language'),
|
||||||
|
inputs: jsonb('inputs'),
|
||||||
|
implementation: text('implementation'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName),
|
||||||
|
index('idx_tools_scope').on(table.scope),
|
||||||
|
index('idx_tools_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Extensions ──
|
||||||
|
// Code-only, no markdown, no chat. Just implementation.
|
||||||
|
|
||||||
|
export const extensions = pgTable('extensions', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dirName: text('dir_name').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
implementation: text('implementation'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName),
|
||||||
|
index('idx_extensions_scope').on(table.scope),
|
||||||
|
index('idx_extensions_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Item Chats ──
|
||||||
|
// Chat history for tasks, skills, processes, resources, tools.
|
||||||
|
// Uses polymorphic reference (item_type + item_id) instead of per-table FKs.
|
||||||
|
|
||||||
|
export const itemChats = pgTable('item_chats', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
itemType: text('item_type').notNull(),
|
||||||
|
itemId: integer('item_id').notNull(),
|
||||||
|
messages: jsonb('messages').notNull().default([]),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
|
||||||
|
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { pgTable, serial, text, integer, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
password: text('password'),
|
||||||
|
role: text('role', { enum: ['Member', 'Admin', 'Owner', 'Super Admin'] }).notNull().default('Member'),
|
||||||
|
status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] }).notNull().default('Unverified'),
|
||||||
|
name: text('name'),
|
||||||
|
username: text('username').unique(),
|
||||||
|
avatar: text('avatar'),
|
||||||
|
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passkeys = pgTable('passkeys', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
origin: text('origin'),
|
||||||
|
credentialId: text('credential_id'),
|
||||||
|
publicKey: text('public_key'),
|
||||||
|
counter: integer('counter').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passkeyChallenges = pgTable('passkey_challenges', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
origin: text('origin').notNull(),
|
||||||
|
challenge: text('challenge').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tokenBlacklist = pgTable('token_blacklist', {
|
||||||
|
jti: text('jti').primaryKey(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_token_blacklist_expires').on(table.expiresAt),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { pgTable, text, integer, boolean, timestamp, jsonb, index, numeric } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
export const chatSessions = pgTable('chat_sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
context: text('context').notNull().default('chat'),
|
||||||
|
contextId: text('context_id'),
|
||||||
|
title: text('title').notNull().default('New Chat'),
|
||||||
|
model: text('model'),
|
||||||
|
cwd: text('cwd'),
|
||||||
|
thinking: text('thinking'),
|
||||||
|
archived: boolean('archived').notNull().default(false),
|
||||||
|
groupSlug: text('group_slug'),
|
||||||
|
messageCount: integer('message_count').notNull().default(0),
|
||||||
|
costInputTokens: integer('cost_input_tokens').notNull().default(0),
|
||||||
|
costOutputTokens: integer('cost_output_tokens').notNull().default(0),
|
||||||
|
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }).notNull().default('0'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_sessions_user_context').on(table.userId, table.context),
|
||||||
|
index('idx_sessions_user_created').on(table.userId, table.createdAt),
|
||||||
|
index('idx_sessions_user_group').on(table.userId, table.groupSlug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const chatMessages = pgTable('chat_messages', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
sessionId: text('session_id').notNull().references(() => chatSessions.id, { onDelete: 'cascade' }),
|
||||||
|
role: text('role').notNull(),
|
||||||
|
text: text('text'),
|
||||||
|
model: text('model'),
|
||||||
|
toolName: text('tool_name'),
|
||||||
|
toolInput: jsonb('tool_input'),
|
||||||
|
toolCallId: text('tool_call_id'),
|
||||||
|
output: text('output'),
|
||||||
|
isError: boolean('is_error').default(false),
|
||||||
|
costInputTokens: integer('cost_input_tokens'),
|
||||||
|
costOutputTokens: integer('cost_output_tokens'),
|
||||||
|
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }),
|
||||||
|
sortOrder: integer('sort_order').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_messages_session_order').on(table.sessionId, table.sortOrder),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const chatGroups = pgTable('chat_groups', {
|
||||||
|
slug: text('slug').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_chat_groups_user').on(table.userId),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './auth';
|
||||||
|
export * from './user-data';
|
||||||
|
export * from './chat';
|
||||||
|
export * from './workspaces';
|
||||||
|
export * from './agent-items';
|
||||||
|
export * from './operations';
|
||||||
|
export * from './server';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { pgTable, serial, text, integer, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
export const taskLogs = pgTable('task_logs', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
taskName: text('task_name').notNull(),
|
||||||
|
taskDirName: text('task_dir_name').notNull(),
|
||||||
|
entryName: text('entry_name').notNull(),
|
||||||
|
entryType: text('entry_type').notNull(),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
model: text('model').notNull(),
|
||||||
|
isError: boolean('is_error').notNull().default(false),
|
||||||
|
messages: jsonb('messages').notNull().default([]),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_task_logs_user_started').on(table.userId, table.startedAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const queueJobs = pgTable('queue_jobs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
lane: text('lane').notNull(),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
status: text('status', { enum: ['queued', 'running', 'completed', 'failed', 'cancelled'] }).notNull().default('queued'),
|
||||||
|
currentStep: integer('current_step').notNull().default(0),
|
||||||
|
steps: jsonb('steps').notNull().default([]),
|
||||||
|
meta: jsonb('meta'),
|
||||||
|
error: text('error'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
}, (table) => [
|
||||||
|
index('idx_queue_jobs_status_lane').on(table.status, table.lane),
|
||||||
|
index('idx_queue_jobs_user').on(table.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const terminalContainers = pgTable('terminal_containers', {
|
||||||
|
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
dockerId: text('docker_id').notNull(),
|
||||||
|
port: integer('port').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { pgTable, serial, boolean, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const serverConfig = pgTable('server_config', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: jsonb('value').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverIntegrations = pgTable('server_integrations', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
provider: text('provider').notNull().unique(),
|
||||||
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
|
config: jsonb('config').notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { pgTable, serial, integer, text, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
import { serverIntegrations } from './server';
|
||||||
|
|
||||||
|
export const userSettings = pgTable('user_settings', {
|
||||||
|
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
settings: jsonb('settings').notNull().default({}),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userState = pgTable('user_state', {
|
||||||
|
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
state: jsonb('state').notNull().default({}),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userIntegrations = pgTable('user_integrations', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
serverIntegrationId: integer('server_integration_id').references(() => serverIntegrations.id, { onDelete: 'set null' }),
|
||||||
|
config: jsonb('config').notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_user_integrations_user_provider').on(table.userId, table.provider),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const dockConfigs = pgTable('dock_configs', {
|
||||||
|
userId: integer('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
paths: jsonb('paths').notNull().default([]),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
export const workspaces = pgTable('workspaces', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
config: jsonb('config').notNull().default({}),
|
||||||
|
layout: jsonb('layout').notNull().default([]),
|
||||||
|
terminals: jsonb('terminals').notNull().default([]),
|
||||||
|
hostTerminals: jsonb('host_terminals').notNull().default([]),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_workspaces_user_id').on(table.userId, table.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const screens = pgTable('screens', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
layout: jsonb('layout').notNull().default([]),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_screens_user_name').on(table.userId, table.name),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const projects = pgTable('projects', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
meta: jsonb('meta').notNull().default({}),
|
||||||
|
layout: jsonb('layout').notNull().default([]),
|
||||||
|
terminals: jsonb('terminals').notNull().default([]),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
unique('uq_projects_user_slug').on(table.userId, table.slug),
|
||||||
|
]);
|
||||||
@@ -1,50 +1,114 @@
|
|||||||
import type { USER_ROLES, USER_STATUSES } from 'definitions';
|
import type * as Schema from './schema';
|
||||||
|
|
||||||
// Auth
|
// ── 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||||
|
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||||
export type User = UserSelect & {
|
export type User = UserSelect & {
|
||||||
passkeys: Passkey[];
|
passkeys: PasskeySelect[];
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PasskeySelect = typeof Schema.passkeys.$inferSelect;
|
||||||
|
export type PasskeyInsert = typeof Schema.passkeys.$inferInsert;
|
||||||
export type Passkey = PasskeySelect & {
|
export type Passkey = PasskeySelect & {
|
||||||
user: User;
|
user: UserSelect;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PasskeyChallengeSelect = typeof Schema.passkeyChallenges.$inferSelect;
|
||||||
|
export type PasskeyChallengeInsert = typeof Schema.passkeyChallenges.$inferInsert;
|
||||||
|
|
||||||
|
export type TokenBlacklistSelect = typeof Schema.tokenBlacklist.$inferSelect;
|
||||||
|
export type TokenBlacklistInsert = typeof Schema.tokenBlacklist.$inferInsert;
|
||||||
|
|
||||||
|
// ── User Data ──
|
||||||
|
|
||||||
|
export type UserSettingsSelect = typeof Schema.userSettings.$inferSelect;
|
||||||
|
export type UserSettingsInsert = typeof Schema.userSettings.$inferInsert;
|
||||||
|
|
||||||
|
export type UserStateSelect = typeof Schema.userState.$inferSelect;
|
||||||
|
export type UserStateInsert = typeof Schema.userState.$inferInsert;
|
||||||
|
|
||||||
|
export type UserIntegrationSelect = typeof Schema.userIntegrations.$inferSelect;
|
||||||
|
export type UserIntegrationInsert = typeof Schema.userIntegrations.$inferInsert;
|
||||||
|
|
||||||
|
export type DockConfigSelect = typeof Schema.dockConfigs.$inferSelect;
|
||||||
|
export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
|
||||||
|
|
||||||
|
// ── Chat ──
|
||||||
|
|
||||||
|
export type ChatSessionSelect = typeof Schema.chatSessions.$inferSelect;
|
||||||
|
export type ChatSessionInsert = typeof Schema.chatSessions.$inferInsert;
|
||||||
|
export type ChatSession = ChatSessionSelect & {
|
||||||
|
messages?: ChatMessageSelect[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessageSelect = typeof Schema.chatMessages.$inferSelect;
|
||||||
|
export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert;
|
||||||
|
|
||||||
|
export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect;
|
||||||
|
export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert;
|
||||||
|
|
||||||
|
// ── Workspaces ──
|
||||||
|
|
||||||
|
export type WorkspaceSelect = typeof Schema.workspaces.$inferSelect;
|
||||||
|
export type WorkspaceInsert = typeof Schema.workspaces.$inferInsert;
|
||||||
|
|
||||||
|
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
||||||
|
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
||||||
|
|
||||||
|
export type ProjectSelect = typeof Schema.projects.$inferSelect;
|
||||||
|
export type ProjectInsert = typeof Schema.projects.$inferInsert;
|
||||||
|
|
||||||
|
// ── Tasks ──
|
||||||
|
|
||||||
|
export type TaskSelect = typeof Schema.tasks.$inferSelect;
|
||||||
|
export type TaskInsert = typeof Schema.tasks.$inferInsert;
|
||||||
|
|
||||||
|
// ── Skills ──
|
||||||
|
|
||||||
|
export type SkillSelect = typeof Schema.skills.$inferSelect;
|
||||||
|
export type SkillInsert = typeof Schema.skills.$inferInsert;
|
||||||
|
|
||||||
|
// ── Processes ──
|
||||||
|
|
||||||
|
export type ProcessSelect = typeof Schema.processes.$inferSelect;
|
||||||
|
export type ProcessInsert = typeof Schema.processes.$inferInsert;
|
||||||
|
|
||||||
|
// ── Resources ──
|
||||||
|
|
||||||
|
export type ResourceSelect = typeof Schema.resources.$inferSelect;
|
||||||
|
export type ResourceInsert = typeof Schema.resources.$inferInsert;
|
||||||
|
|
||||||
|
// ── Tools ──
|
||||||
|
|
||||||
|
export type ToolSelect = typeof Schema.tools.$inferSelect;
|
||||||
|
export type ToolInsert = typeof Schema.tools.$inferInsert;
|
||||||
|
|
||||||
|
// ── Extensions ──
|
||||||
|
|
||||||
|
export type ExtensionSelect = typeof Schema.extensions.$inferSelect;
|
||||||
|
export type ExtensionInsert = typeof Schema.extensions.$inferInsert;
|
||||||
|
|
||||||
|
// ── Item Chats ──
|
||||||
|
|
||||||
|
export type ItemChatSelect = typeof Schema.itemChats.$inferSelect;
|
||||||
|
export type ItemChatInsert = typeof Schema.itemChats.$inferInsert;
|
||||||
|
|
||||||
|
// ── Operations ──
|
||||||
|
|
||||||
|
export type TaskLogSelect = typeof Schema.taskLogs.$inferSelect;
|
||||||
|
export type TaskLogInsert = typeof Schema.taskLogs.$inferInsert;
|
||||||
|
|
||||||
|
export type QueueJobSelect = typeof Schema.queueJobs.$inferSelect;
|
||||||
|
export type QueueJobInsert = typeof Schema.queueJobs.$inferInsert;
|
||||||
|
|
||||||
|
export type TerminalContainerSelect = typeof Schema.terminalContainers.$inferSelect;
|
||||||
|
export type TerminalContainerInsert = typeof Schema.terminalContainers.$inferInsert;
|
||||||
|
|
||||||
|
// ── Server ──
|
||||||
|
|
||||||
|
export type ServerConfigSelect = typeof Schema.serverConfig.$inferSelect;
|
||||||
|
export type ServerConfigInsert = typeof Schema.serverConfig.$inferInsert;
|
||||||
|
|
||||||
|
export type ServerIntegrationSelect = typeof Schema.serverIntegrations.$inferSelect;
|
||||||
|
export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferInsert;
|
||||||
|
|||||||
+2
-2
@@ -50,7 +50,7 @@ const devServerWebsocket = {
|
|||||||
try {
|
try {
|
||||||
const payload = await verify(wsToken);
|
const payload = await verify(wsToken);
|
||||||
if (!payload) { ws.close(4001, 'Unauthorized'); return; }
|
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 {
|
} catch {
|
||||||
ws.close(4001, 'Unauthorized');
|
ws.close(4001, 'Unauthorized');
|
||||||
return;
|
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) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
if (user.jti) {
|
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);
|
const url = new URL(req.url);
|
||||||
|
|||||||
@@ -46,16 +46,16 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
|||||||
|
|
||||||
// Check if token is blacklisted (explicit signout)
|
// Check if token is blacklisted (explicit signout)
|
||||||
if (user.jti) {
|
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
|
// Check if token was issued before password change
|
||||||
if (user.iat && user.id) {
|
if (user.iat && user.id) {
|
||||||
const dbUser = getUserById(user.id);
|
const dbUser = await getUserById(user.id);
|
||||||
if (dbUser?.passwordChangedAt) {
|
if (dbUser?.passwordChangedAt) {
|
||||||
// iat is in seconds, passwordChangedAt is in milliseconds
|
// iat is in seconds, passwordChangedAt is a Date
|
||||||
const tokenIssuedAt = user.iat * 1000;
|
const tokenIssuedAt = user.iat * 1000;
|
||||||
if (tokenIssuedAt < dbUser.passwordChangedAt) {
|
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
|
||||||
throw errors.UNAUTHORIZED();
|
throw errors.UNAUTHORIZED();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
const token = body.token as string;
|
const token = body.token as string;
|
||||||
const email = body.email 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 (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
|||||||
if (isProduction) validatePassword(newPassword);
|
if (isProduction) validatePassword(newPassword);
|
||||||
const reqUser = ctx.get('user');
|
const reqUser = ctx.get('user');
|
||||||
|
|
||||||
const dbUser = getUserById(reqUser.id);
|
const dbUser = await getUserById(reqUser.id);
|
||||||
|
|
||||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
@@ -23,8 +23,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newPasswordHash = await argon2.hash(newPassword);
|
const newPasswordHash = await argon2.hash(newPassword);
|
||||||
// Use floored seconds-to-ms so the token iat (also floored) is never behind
|
const passwordChangedAt = new Date();
|
||||||
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
|
|
||||||
await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt });
|
await updateUser(reqUser.id, { password: newPasswordHash, passwordChangedAt });
|
||||||
|
|
||||||
const { id, email, name, role } = reqUser;
|
const { id, email, name, role } = reqUser;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
|
|||||||
const { email } = ctx.get('body');
|
const { email } = ctx.get('body');
|
||||||
const origin = ctx.get('origin');
|
const origin = ctx.get('origin');
|
||||||
|
|
||||||
const dbUser = getUserByEmail(email);
|
const dbUser = await getUserByEmail(email);
|
||||||
|
|
||||||
if (!dbUser) return ctx.json({ ok: true });
|
if (!dbUser) return ctx.json({ ok: true });
|
||||||
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { sign } from '../../jwt';
|
|||||||
import * as errors from '../../custom-errors';
|
import * as errors from '../../custom-errors';
|
||||||
import {
|
import {
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getPasskeysByEmailAndOrigin,
|
getPasskeysByUserIdAndOrigin,
|
||||||
getPasskeyByCredentialId,
|
getPasskeyByCredentialId,
|
||||||
createPasskey,
|
createPasskey,
|
||||||
updatePasskey,
|
updatePasskey,
|
||||||
@@ -41,8 +41,11 @@ const passkeyRouterPostChallenge: Handler = async (ctx) => {
|
|||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
|
|
||||||
|
const dbUser = await getUserByEmail(email!);
|
||||||
|
if (!dbUser) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Get existing passkeys to exclude them
|
// Get existing passkeys to exclude them
|
||||||
const existingPasskeys = getPasskeysByEmailAndOrigin(email!, origin);
|
const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName: RP_NAME,
|
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);
|
return ctx.json(options);
|
||||||
};
|
};
|
||||||
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
|
||||||
@@ -68,10 +71,10 @@ passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostCha
|
|||||||
const passkeyRouterPost: Handler = async (ctx) => {
|
const passkeyRouterPost: Handler = async (ctx) => {
|
||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
const rpId = getRpId(origin);
|
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 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();
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
const verification = await verifyRegistrationResponse({
|
||||||
@@ -88,7 +91,7 @@ const passkeyRouterPost: Handler = async (ctx) => {
|
|||||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||||
|
|
||||||
await createPasskey({
|
await createPasskey({
|
||||||
email,
|
userId: user.id,
|
||||||
origin,
|
origin,
|
||||||
credentialId: credential.id,
|
credentialId: credential.id,
|
||||||
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
publicKey: Buffer.from(credential.publicKey).toString('base64'),
|
||||||
@@ -105,7 +108,10 @@ const passkeyRouterGet: Handler = async (ctx) => {
|
|||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
const rpId = getRpId(origin);
|
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({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID: rpId,
|
rpID: rpId,
|
||||||
@@ -115,7 +121,7 @@ const passkeyRouterGet: Handler = async (ctx) => {
|
|||||||
userVerification: 'preferred',
|
userVerification: 'preferred',
|
||||||
});
|
});
|
||||||
|
|
||||||
await storeChallenge(email!, origin, options.challenge);
|
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
|
||||||
return ctx.json(options);
|
return ctx.json(options);
|
||||||
};
|
};
|
||||||
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
|
||||||
@@ -127,11 +133,14 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
const rpId = getRpId(origin);
|
const rpId = getRpId(origin);
|
||||||
const response = ctx.get('body') as AuthenticationResponseJSON;
|
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();
|
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
// Find the passkey being used
|
// 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();
|
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
|
||||||
|
|
||||||
@@ -152,10 +161,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
// Update counter to prevent replay attacks
|
// Update counter to prevent replay attacks
|
||||||
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
|
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
|
||||||
|
|
||||||
const dbUser = getUserByEmail(email!);
|
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
|
||||||
|
|
||||||
const passkeys = getPasskeysByEmailAndOrigin(email!, origin);
|
|
||||||
|
|
||||||
const { id, name, username, role } = dbUser;
|
const { id, name, username, role } = dbUser;
|
||||||
const token = await sign({
|
const token = await sign({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const resendVerificationHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
|
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) throw errors.NOT_FOUND('User not found');
|
||||||
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import * as errors from '@@/custom-errors';
|
|||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
|
|
||||||
export const resetPasswordHandler: Handler = async function (ctx) {
|
export const resetPasswordHandler: Handler = async function (ctx) {
|
||||||
const now = Date.now().valueOf();
|
|
||||||
const { password, verificationCode } = ctx.get('body');
|
const { password, verificationCode } = ctx.get('body');
|
||||||
validatePassword(password);
|
validatePassword(password);
|
||||||
const userInfo = (await verify(verificationCode)) as User;
|
const userInfo = (await verify(verificationCode)) as User;
|
||||||
if (!userInfo) throw errors.UNAUTHORIZED();
|
if (!userInfo) throw errors.UNAUTHORIZED();
|
||||||
const passwordHash = await argon2.hash(password);
|
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 });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getUserByEmail, getPasskeysByEmailAndOrigin } from 'officerdb';
|
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { getClaudeDir } from '@@/data-path';
|
import { getClaudeDir } from '@@/data-path';
|
||||||
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
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) {
|
export const signinHandler: Handler = async function (ctx) {
|
||||||
const { email, password } = ctx.get('body');
|
const { email, password } = ctx.get('body');
|
||||||
const origin = ctx.get('origin');
|
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;
|
const { status } = dbUser;
|
||||||
if (status !== 'Active') throw errors.UNAUTHORIZED();
|
if (status !== 'Active') throw errors.UNAUTHORIZED();
|
||||||
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const signupHandler: Handler = async function (ctx) {
|
|||||||
throw errors.BAD_REQUEST('Invalid email address');
|
throw errors.BAD_REQUEST('Invalid email address');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCount = getUserCount();
|
const userCount = await getUserCount();
|
||||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
const dbUser = await createUser({
|
const dbUser = await createUser({
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { getUserById, getPasskeysByEmailAndOrigin } from 'officerdb';
|
import { getUserById, getPasskeysByUserIdAndOrigin } from 'officerdb';
|
||||||
|
|
||||||
export const usersMe: Handler = async function (ctx) {
|
export const usersMe: Handler = async function (ctx) {
|
||||||
const user = ctx.get('user') as User;
|
const user = ctx.get('user') as User;
|
||||||
const origin = ctx.get('origin') as string;
|
const origin = ctx.get('origin') as string;
|
||||||
|
|
||||||
const dbUser = getUserById(user.id);
|
const dbUser = await getUserById(user.id);
|
||||||
|
|
||||||
if (!dbUser) return errors.NOT_FOUND();
|
if (!dbUser) return errors.NOT_FOUND();
|
||||||
|
|
||||||
const passkeys = getPasskeysByEmailAndOrigin(dbUser.email, origin || '');
|
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin || '');
|
||||||
const { password, ...userWithoutPassword } = dbUser;
|
const { password, ...userWithoutPassword } = dbUser;
|
||||||
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
|
const returnUser = { ...userWithoutPassword, passkeyCount: passkeys.length };
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
|
|||||||
|
|
||||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
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');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Reset-password tokens skip the verification status check
|
// Reset-password tokens skip the verification status check
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
|||||||
const userInfo = (await verifyJwt(verificationCode)) as User;
|
const userInfo = (await verifyJwt(verificationCode)) as User;
|
||||||
if (!userInfo) throw errors.BAD_REQUEST();
|
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');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { status: 'Active' };
|
const updates: Record<string, unknown> = { status: 'Active' };
|
||||||
@@ -38,7 +38,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
|||||||
await updateUser(userInfo.id, updates);
|
await updateUser(userInfo.id, updates);
|
||||||
|
|
||||||
// Re-fetch user to get final values after update
|
// 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');
|
if (!finalUser) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Issue a token so the user is logged in immediately
|
// Issue a token so the user is logged in immediately
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ async function validateJwt(req: Request): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
const payload = await verify(token);
|
const payload = await verify(token);
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
if (payload.jti && isTokenBlacklisted(payload.jti)) return false;
|
if (payload.jti && await isTokenBlacklisted(payload.jti)) return false;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ import { getUserCount } from 'officerdb';
|
|||||||
export const landingPageDataRouter = createRouter();
|
export const landingPageDataRouter = createRouter();
|
||||||
|
|
||||||
landingPageDataRouter.get('/', async (ctx) => {
|
landingPageDataRouter.get('/', async (ctx) => {
|
||||||
const userCount = getUserCount();
|
const userCount = await getUserCount();
|
||||||
return ctx.json({ registrationOpen: userCount === 0 });
|
return ctx.json({ registrationOpen: userCount === 0 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import type { Subprocess } from "bun";
|
|||||||
import type { PiEvent, MessageCost } from "./types";
|
import type { PiEvent, MessageCost } from "./types";
|
||||||
import { readApiKeys } from "../server-settings/pi-mono";
|
import { readApiKeys } from "../server-settings/pi-mono";
|
||||||
import { readSearxngConfig } from "../server-settings/searxng";
|
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 { ensureDockerContainer } from "../terminal/websocket";
|
||||||
|
import { getServerIntegration, getUserIntegration } from "officerdb";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { parseFrontmatter } from "../skills/skills";
|
import { parseFrontmatter } from "../skills/skills";
|
||||||
|
|
||||||
@@ -159,12 +160,33 @@ function buildResourcesEnv(): string {
|
|||||||
return JSON.stringify(result);
|
return JSON.stringify(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGoogleConfigPath(): string {
|
async function ensureGoogleConfigFile(): Promise<string> {
|
||||||
return join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
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 {
|
async function ensureGoogleTokenFile(userId: number, email: string): Promise<string> {
|
||||||
return join(DATA_PATH, email, 'integrations', 'google.json');
|
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 = {
|
type SandboxOptions = {
|
||||||
@@ -177,6 +199,7 @@ type SandboxOptions = {
|
|||||||
export async function spawnPi(
|
export async function spawnPi(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
model: string,
|
model: string,
|
||||||
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
onEvent: PiEventHandler,
|
onEvent: PiEventHandler,
|
||||||
sandbox?: SandboxOptions,
|
sandbox?: SandboxOptions,
|
||||||
@@ -217,8 +240,8 @@ export async function spawnPi(
|
|||||||
|
|
||||||
const resourcesEnv = buildResourcesEnv();
|
const resourcesEnv = buildResourcesEnv();
|
||||||
|
|
||||||
const googleConfigHost = getGoogleConfigPath();
|
const googleConfigHost = await ensureGoogleConfigFile();
|
||||||
const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations');
|
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
|
||||||
|
|
||||||
const envFlags = [
|
const envFlags = [
|
||||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
||||||
@@ -275,13 +298,28 @@ export async function spawnPi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||||
|
const googleConfigPath = await ensureGoogleConfigFile();
|
||||||
|
const googleTokenPath = await ensureGoogleTokenFile(userId, email);
|
||||||
|
|
||||||
proc = Bun.spawn(args, {
|
proc = Bun.spawn(args, {
|
||||||
cwd,
|
cwd,
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: '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', {
|
logger.info('Spawned Pi locally', {
|
||||||
|
|||||||
@@ -6,22 +6,20 @@ import * as storage from './storage';
|
|||||||
import * as piBridge from './pi-bridge';
|
import * as piBridge from './pi-bridge';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { homedir } from 'os';
|
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';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// Default model when no user preference is set
|
// Default model when no user preference is set
|
||||||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||||
|
|
||||||
async function getUserDefaultModel(email: string): Promise<string | null> {
|
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const settingsPath = getUserSettingsFile(email);
|
const settings = await getUserSettings(userId);
|
||||||
const file = Bun.file(settingsPath);
|
const chat = settings?.chat as Record<string, unknown> | undefined;
|
||||||
if (await file.exists()) {
|
return (chat?.defaultModel as string) || null;
|
||||||
const settings = await file.json();
|
|
||||||
return settings?.chat?.defaultModel || null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -254,7 +252,7 @@ async function handleChat(
|
|||||||
let modelSource = 'client-provided';
|
let modelSource = 'client-provided';
|
||||||
let userDefault = null;
|
let userDefault = null;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
userDefault = await getUserDefaultModel(email);
|
userDefault = await getUserDefaultModel(userId);
|
||||||
if (userDefault) {
|
if (userDefault) {
|
||||||
model = userDefault;
|
model = userDefault;
|
||||||
modelSource = 'user-settings';
|
modelSource = 'user-settings';
|
||||||
@@ -288,7 +286,7 @@ async function handleChat(
|
|||||||
if (!session.piProcess) {
|
if (!session.piProcess) {
|
||||||
try {
|
try {
|
||||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
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 });
|
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(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 homeDir = getHomeDir(email);
|
||||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
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);
|
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 });
|
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { settingsPath } from './server-settings';
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||||
import { readResourceConfig } from './resources';
|
import { readResourceConfig } from './resources';
|
||||||
|
|
||||||
type OcrConfig = {
|
type OcrConfig = {
|
||||||
@@ -10,8 +10,8 @@ type OcrConfig = {
|
|||||||
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
|
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
|
||||||
const config = await readResourceConfig('optical-character-recognition');
|
const config = await readResourceConfig('optical-character-recognition');
|
||||||
if (config.url) return { url: config.url, model: config.model ?? '' };
|
if (config.url) return { url: config.url, model: config.model ?? '' };
|
||||||
// Fallback to legacy settings.json
|
// Fallback to legacy DB settings
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
return settings.ocr as OcrConfig | undefined;
|
return settings.ocr as OcrConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ ocrRouter.get('/', async (ctx) => {
|
|||||||
|
|
||||||
ocrRouter.put('/', async (ctx) => {
|
ocrRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json<OcrConfig>();
|
const body = await ctx.req.json<OcrConfig>();
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
settings.ocr = body;
|
settings.ocr = body;
|
||||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
await writeServerSettings(settings);
|
||||||
return ctx.json({ success: true });
|
return ctx.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createRouter } from '../../create-router';
|
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 { readdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||||
import { claudeCodeRouter } from './claude-code';
|
import { claudeCodeRouter } from './claude-code';
|
||||||
import { opencodeRouter } from './opencode';
|
import { opencodeRouter } from './opencode';
|
||||||
import { piMonoRouter } from './pi-mono';
|
import { piMonoRouter } from './pi-mono';
|
||||||
@@ -14,14 +13,6 @@ import { sttRouter } from './stt';
|
|||||||
import { ocrRouter } from './ocr';
|
import { ocrRouter } from './ocr';
|
||||||
import { searxngRouter } from './searxng';
|
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();
|
export const serverSettingsRouter = createRouter();
|
||||||
|
|
||||||
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
|
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
|
||||||
@@ -35,33 +26,29 @@ serverSettingsRouter.route('/stt', sttRouter);
|
|||||||
serverSettingsRouter.route('/ocr', ocrRouter);
|
serverSettingsRouter.route('/ocr', ocrRouter);
|
||||||
serverSettingsRouter.route('/searxng', searxngRouter);
|
serverSettingsRouter.route('/searxng', searxngRouter);
|
||||||
|
|
||||||
export const readSettings = async () => {
|
export { readServerSettings as readSettings };
|
||||||
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
|
|
||||||
};
|
|
||||||
|
|
||||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||||
return ctx.json(await readSettings());
|
return ctx.json(await readServerSettings());
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
||||||
const settings = await readSettings();
|
const settings = await readServerSettings();
|
||||||
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSettingsRouter.put('/', async (ctx) => {
|
serverSettingsRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json();
|
const body = await ctx.req.json();
|
||||||
const settings = await readSettings();
|
const settings = await readServerSettings();
|
||||||
const updated = { ...settings, ...body };
|
const updated = { ...settings, ...body };
|
||||||
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
|
await writeServerSettings(updated);
|
||||||
return ctx.json(updated);
|
return ctx.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
serverSettingsRouter.get('/plugins', async (ctx) => {
|
serverSettingsRouter.get('/plugins', async (ctx) => {
|
||||||
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
|
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
|
||||||
const settings = await Bun.file(settingsPath)
|
const settings = await readServerSettings();
|
||||||
.json()
|
const pluginSettings: Record<string, boolean> = (settings.plugins as Record<string, boolean>) ?? {};
|
||||||
.catch(() => ({}));
|
|
||||||
const pluginSettings: Record<string, boolean> = settings.plugins ?? {};
|
|
||||||
|
|
||||||
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
|
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { settingsPath } from './server-settings';
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||||
import { getTransport } from 'emailer';
|
import { getTransport } from 'emailer';
|
||||||
|
|
||||||
type SmtpConfig = {
|
type SmtpConfig = {
|
||||||
@@ -46,24 +46,24 @@ function buildTransportUrl(config: SmtpConfig): string {
|
|||||||
export const smtpRouter = createRouter();
|
export const smtpRouter = createRouter();
|
||||||
|
|
||||||
smtpRouter.get('/', async (ctx) => {
|
smtpRouter.get('/', async (ctx) => {
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
const smtp: SmtpConfig | undefined = settings.smtp;
|
const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
||||||
if (smtp) return ctx.json(serializeConfig(smtp));
|
if (smtp) return ctx.json(serializeConfig(smtp));
|
||||||
return ctx.json(null);
|
return ctx.json(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
smtpRouter.put('/', async (ctx) => {
|
smtpRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json<SmtpConfig>();
|
const body = await ctx.req.json<SmtpConfig>();
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
|
|
||||||
const existing: SmtpConfig | undefined = settings.smtp;
|
const existing: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (body.apiKey && body.apiKey.includes('****')) body.apiKey = existing.apiKey;
|
if (body.apiKey && body.apiKey.includes('****')) body.apiKey = existing.apiKey;
|
||||||
if (body.password && body.password.includes('****')) body.password = existing.password;
|
if (body.password && body.password.includes('****')) body.password = existing.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.smtp = body;
|
settings.smtp = body;
|
||||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
await writeServerSettings(settings);
|
||||||
return ctx.json({ success: true });
|
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);
|
if (!body.to) return ctx.json({ error: 'Recipient address required' }, 400);
|
||||||
|
|
||||||
// Resolve masked secrets from saved config
|
// Resolve masked secrets from saved config
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
const saved: SmtpConfig | undefined = settings.smtp;
|
const saved: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
||||||
if (saved) {
|
if (saved) {
|
||||||
if (body.apiKey?.includes('****')) body.apiKey = saved.apiKey;
|
if (body.apiKey?.includes('****')) body.apiKey = saved.apiKey;
|
||||||
if (body.password?.includes('****')) body.password = saved.password;
|
if (body.password?.includes('****')) body.password = saved.password;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { settingsPath } from './server-settings';
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||||
import { readResourceConfig } from './resources';
|
import { readResourceConfig } from './resources';
|
||||||
|
|
||||||
type SttConfig = {
|
type SttConfig = {
|
||||||
@@ -9,8 +9,8 @@ type SttConfig = {
|
|||||||
export async function readSttConfig(): Promise<SttConfig | undefined> {
|
export async function readSttConfig(): Promise<SttConfig | undefined> {
|
||||||
const config = await readResourceConfig('speech-to-text');
|
const config = await readResourceConfig('speech-to-text');
|
||||||
if (config.url) return { url: config.url };
|
if (config.url) return { url: config.url };
|
||||||
// Fallback to legacy settings.json
|
// Fallback to legacy DB settings
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
return settings.stt as SttConfig | undefined;
|
return settings.stt as SttConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,9 +24,9 @@ sttRouter.get('/', async (ctx) => {
|
|||||||
|
|
||||||
sttRouter.put('/', async (ctx) => {
|
sttRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json<SttConfig>();
|
const body = await ctx.req.json<SttConfig>();
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
settings.stt = body;
|
settings.stt = body;
|
||||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
await writeServerSettings(settings);
|
||||||
return ctx.json({ success: true });
|
return ctx.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export async function syncUserPiConfig(email: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncAllUserPiConfigs(): Promise<void> {
|
export async function syncAllUserPiConfigs(): Promise<void> {
|
||||||
const users = getUsers();
|
const users = await getUsers();
|
||||||
if (users.length === 0) return;
|
if (users.length === 0) return;
|
||||||
|
|
||||||
const [appConfig, policy, apiKeys] = await Promise.all([
|
const [appConfig, policy, apiKeys] = await Promise.all([
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { settingsPath } from './server-settings';
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||||
import { readResourceConfig } from './resources';
|
import { readResourceConfig } from './resources';
|
||||||
|
|
||||||
type TtsConfig = {
|
type TtsConfig = {
|
||||||
@@ -18,8 +18,8 @@ function maskSecret(value: string | undefined): string | undefined {
|
|||||||
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
|
export async function readTtsConfig(): Promise<TtsConfig | undefined> {
|
||||||
const config = await readResourceConfig('text-to-speech');
|
const config = await readResourceConfig('text-to-speech');
|
||||||
if (!config.url && !config.provider) {
|
if (!config.url && !config.provider) {
|
||||||
// Fallback to legacy settings.json
|
// Fallback to legacy DB settings
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
return settings.tts as TtsConfig | undefined;
|
return settings.tts as TtsConfig | undefined;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -41,15 +41,15 @@ ttsRouter.get('/', async (ctx) => {
|
|||||||
|
|
||||||
ttsRouter.put('/', async (ctx) => {
|
ttsRouter.put('/', async (ctx) => {
|
||||||
const body = await ctx.req.json<TtsConfig>();
|
const body = await ctx.req.json<TtsConfig>();
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
|
|
||||||
const existing: TtsConfig | undefined = settings.tts;
|
const existing: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
|
||||||
if (existing && body.apiKey?.includes('****')) {
|
if (existing && body.apiKey?.includes('****')) {
|
||||||
body.apiKey = existing.apiKey;
|
body.apiKey = existing.apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.tts = body;
|
settings.tts = body;
|
||||||
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
|
await writeServerSettings(settings);
|
||||||
return ctx.json({ success: true });
|
return ctx.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,8 +152,8 @@ async function fetchHuggingFaceVoices(repoId: string): Promise<{ flat: string[];
|
|||||||
ttsRouter.post('/test', async (ctx) => {
|
ttsRouter.post('/test', async (ctx) => {
|
||||||
const body = await ctx.req.json<TtsConfig>();
|
const body = await ctx.req.json<TtsConfig>();
|
||||||
|
|
||||||
const settings = await Bun.file(settingsPath).json().catch(() => ({}));
|
const settings = await readServerSettings();
|
||||||
const saved: TtsConfig | undefined = settings.tts;
|
const saved: TtsConfig | undefined = settings.tts as TtsConfig | undefined;
|
||||||
if (saved && body.apiKey?.includes('****')) {
|
if (saved && body.apiKey?.includes('****')) {
|
||||||
body.apiKey = saved.apiKey;
|
body.apiKey = saved.apiKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { getUserSettingsFile, getUserStateFile } from '@@/data-path';
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
chat: {
|
chat: {
|
||||||
defaultProvider: 'claude',
|
defaultProvider: 'pi',
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
@@ -16,73 +14,40 @@ const DEFAULT_SETTINGS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
|
|
||||||
|
|
||||||
export const settingsRouter = createRouter();
|
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) => {
|
settingsRouter.get('/settings', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const userId = ctx.get('user').id;
|
||||||
const filePath = getUserSettingsFile(email);
|
const settings = await getUserSettings(userId);
|
||||||
const file = Bun.file(filePath);
|
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (Object.keys(settings).length === 0) {
|
||||||
try {
|
await setUserSettings(userId, DEFAULT_SETTINGS);
|
||||||
return ctx.json(await file.json());
|
return ctx.json(DEFAULT_SETTINGS);
|
||||||
} catch {
|
|
||||||
// corrupted — fall through to defaults
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(filePath);
|
return ctx.json(settings);
|
||||||
await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2));
|
|
||||||
return ctx.json(DEFAULT_SETTINGS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /settings — full replacement
|
// PUT /settings — full replacement
|
||||||
settingsRouter.put('/settings', async (ctx) => {
|
settingsRouter.put('/settings', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const userId = ctx.get('user').id;
|
||||||
const body = ctx.get('body');
|
const body = ctx.get('body');
|
||||||
const filePath = getUserSettingsFile(email);
|
await setUserSettings(userId, body);
|
||||||
|
|
||||||
await ensureDir(filePath);
|
|
||||||
await Bun.write(filePath, JSON.stringify(body, null, 2));
|
|
||||||
return ctx.json(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) => {
|
settingsRouter.get('/state', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const userId = ctx.get('user').id;
|
||||||
const filePath = getUserStateFile(email);
|
const state = await getUserState(userId);
|
||||||
const file = Bun.file(filePath);
|
return ctx.json(state);
|
||||||
|
|
||||||
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({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /state — shallow-merge incoming keys
|
// PATCH /state — shallow-merge incoming keys
|
||||||
settingsRouter.patch('/state', async (ctx) => {
|
settingsRouter.patch('/state', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const userId = ctx.get('user').id;
|
||||||
const body = ctx.get('body');
|
const body = ctx.get('body');
|
||||||
const filePath = getUserStateFile(email);
|
const merged = await patchUserState(userId, body);
|
||||||
const file = Bun.file(filePath);
|
|
||||||
|
|
||||||
let existing: Record<string, unknown> = {};
|
|
||||||
if (await file.exists()) {
|
|
||||||
try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = { ...existing, ...body };
|
|
||||||
await ensureDir(filePath);
|
|
||||||
await Bun.write(filePath, JSON.stringify(merged, null, 2));
|
|
||||||
return ctx.json(merged);
|
return ctx.json(merged);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR } from '@@/data-path';
|
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 { 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 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 };
|
type ShellInfo = { command: string; args: string[]; name: string };
|
||||||
@@ -113,10 +113,10 @@ const containerHasExpectedMounts = (dockerId: string): boolean => {
|
|||||||
});
|
});
|
||||||
if (result.exitCode !== 0) return false;
|
if (result.exitCode !== 0) return false;
|
||||||
const mounts = result.stdout.toString();
|
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();
|
ensureDockerImage();
|
||||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||||
const dockerId = `officer-terminal-${userId}`;
|
const dockerId = `officer-terminal-${userId}`;
|
||||||
@@ -138,10 +138,32 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
|||||||
}
|
}
|
||||||
|
|
||||||
const containerHome = `/home/${username}`;
|
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 googleConfigHost = join(SERVER_CONFIG_DIR, 'google-oauth.json');
|
||||||
const googleMounts: string[] = existsSync(googleConfigHost)
|
let googleMounts: string[] = [];
|
||||||
? ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`]
|
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({
|
const run = Bun.spawnSync({
|
||||||
cmd: [
|
cmd: [
|
||||||
@@ -273,7 +295,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
|||||||
}
|
}
|
||||||
|
|
||||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
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 };
|
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||||
map[email] = next;
|
map[email] = next;
|
||||||
await saveContainerMap(map);
|
await saveContainerMap(map);
|
||||||
@@ -321,7 +343,7 @@ const startHostSidecar = async () => {
|
|||||||
export const initTerminalSidecars = async () => {
|
export const initTerminalSidecars = async () => {
|
||||||
await startHostSidecar();
|
await startHostSidecar();
|
||||||
ensureDockerImage();
|
ensureDockerImage();
|
||||||
const users = getUsers();
|
const users = await getUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const homeDir = getHomeDir(user.email);
|
const homeDir = getHomeDir(user.email);
|
||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ usersRouter.get('/', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||||
|
|
||||||
const users = getUsers();
|
const users = await getUsers();
|
||||||
const sanitized = users.map(({ password, ...rest }) => rest);
|
const sanitized = users.map(({ password, ...rest }) => rest);
|
||||||
|
|
||||||
return ctx.json(sanitized);
|
return ctx.json(sanitized);
|
||||||
@@ -39,7 +39,7 @@ usersRouter.post('/invite', async (ctx) => {
|
|||||||
? (role as (typeof USER_ROLES)[number])
|
? (role as (typeof USER_ROLES)[number])
|
||||||
: ('Member' as const);
|
: ('Member' as const);
|
||||||
|
|
||||||
const existing = getUserByEmail(email);
|
const existing = await getUserByEmail(email);
|
||||||
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||||
|
|
||||||
const dbUser = await createUser({
|
const dbUser = await createUser({
|
||||||
@@ -71,7 +71,7 @@ usersRouter.post('/:id/resend-invite', async (ctx) => {
|
|||||||
const id = Number(ctx.req.param('id'));
|
const id = Number(ctx.req.param('id'));
|
||||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user 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) throw errors.NOT_FOUND('User not found');
|
||||||
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
|
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 || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||||
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
|
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');
|
if (!target) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
await deleteUser(id);
|
await deleteUser(id);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { homedir } from 'node:os';
|
|||||||
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
||||||
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
||||||
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
||||||
import { initAuthStore } from 'officerdb';
|
|
||||||
import { syncSeedSkills } from './sync-skills';
|
import { syncSeedSkills } from './sync-skills';
|
||||||
import { syncSeedTools } from './sync-tools';
|
import { syncSeedTools } from './sync-tools';
|
||||||
import { syncSeedExtensions } from './sync-extensions';
|
import { syncSeedExtensions } from './sync-extensions';
|
||||||
@@ -16,8 +15,6 @@ import { initQueue } from './queue';
|
|||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
||||||
|
|
||||||
await initAuthStore();
|
|
||||||
|
|
||||||
async function ensurePiInstalled(): Promise<boolean> {
|
async function ensurePiInstalled(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
@@ -80,7 +77,7 @@ function seedPiConfig(): void {
|
|||||||
syncSeedTools();
|
syncSeedTools();
|
||||||
syncSeedExtensions();
|
syncSeedExtensions();
|
||||||
syncSeedResources();
|
syncSeedResources();
|
||||||
migrateSettingsToResources();
|
await migrateSettingsToResources();
|
||||||
generateResourceSkill(DATA_PATH);
|
generateResourceSkill(DATA_PATH);
|
||||||
|
|
||||||
await syncLocalProvidersToPiConfig().catch(err => {
|
await syncLocalProvidersToPiConfig().catch(err => {
|
||||||
|
|||||||
+2
-2
@@ -51,8 +51,8 @@ honoServer.route('/api/waitlist', waitlistRouter);
|
|||||||
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
||||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => {
|
honoServer.get('/api/server-settings/onboarding-complete', async (ctx) => {
|
||||||
const { readSettings } = await import('./api/server-settings/server-settings');
|
const { readServerSettings } = await import('officerdb');
|
||||||
const settings = await readSettings();
|
const settings = await readServerSettings();
|
||||||
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { join } from 'node:path';
|
||||||
import { DATA_PATH, SEED_PATH } from './data-path';
|
import { DATA_PATH, SEED_PATH } from './data-path';
|
||||||
import { settingsPath } from './api/server-settings/server-settings';
|
import { readServerSettings } from 'officerdb';
|
||||||
|
|
||||||
const SETTINGS_TO_RESOURCE: Record<string, string> = {
|
const SETTINGS_TO_RESOURCE: Record<string, string> = {
|
||||||
stt: 'speech-to-text',
|
stt: 'speech-to-text',
|
||||||
@@ -9,10 +9,10 @@ const SETTINGS_TO_RESOURCE: Record<string, string> = {
|
|||||||
ocr: 'optical-character-recognition',
|
ocr: 'optical-character-recognition',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function migrateSettingsToResources(): void {
|
export async function migrateSettingsToResources(): Promise<void> {
|
||||||
let settings: Record<string, Record<string, string>> = {};
|
let settings: Record<string, Record<string, string>>;
|
||||||
try {
|
try {
|
||||||
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
settings = (await readServerSettings()) as Record<string, Record<string, string>>;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { join } from 'node:path';
|
|
||||||
import type { Database } from 'bun:sqlite';
|
import type { Database } from 'bun:sqlite';
|
||||||
import type { JobHandler } from '../types';
|
import type { JobHandler } from '../types';
|
||||||
import { registerHandler } from '../handler-registry';
|
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 { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta, updateEmailLabels } from '../../api/email/email-db';
|
||||||
|
import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb';
|
||||||
|
|
||||||
type GoogleCredentials = {
|
type GoogleCredentials = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -13,26 +13,28 @@ type GoogleCredentials = {
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const googleConfigPath = join(SERVER_CONFIG_DIR, 'google-oauth.json');
|
async function loadCredentials(email: string): Promise<GoogleCredentials> {
|
||||||
|
const serverGoogle = await getServerIntegration('google');
|
||||||
async function loadCredentials(userId: string): Promise<GoogleCredentials> {
|
const serverConfig = serverGoogle?.config as Record<string, unknown> | undefined;
|
||||||
const config = await Bun.file(googleConfigPath).json().catch(() => null);
|
if (!serverConfig?.clientId || !serverConfig?.clientSecret) {
|
||||||
if (!config?.clientId || !config?.clientSecret) {
|
|
||||||
throw new Error('Google OAuth not configured — ask your admin to set up credentials');
|
throw new Error('Google OAuth not configured — ask your admin to set up credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenPath = join(DATA_PATH, userId, 'integrations', 'google.json');
|
const dbUser = await getUserByEmail(email);
|
||||||
const token = await Bun.file(tokenPath).json().catch(() => null);
|
if (!dbUser) throw new Error('User not found');
|
||||||
if (!token?.accessToken) {
|
|
||||||
|
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
||||||
|
const userConfig = userGoogle?.config as Record<string, unknown> | undefined;
|
||||||
|
if (!userConfig?.accessToken) {
|
||||||
throw new Error('Google account not connected — connect in Settings → Integrations');
|
throw new Error('Google account not connected — connect in Settings → Integrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: token.accessToken,
|
accessToken: userConfig.accessToken as string,
|
||||||
refreshToken: token.refreshToken ?? '',
|
refreshToken: (userConfig.refreshToken as string) ?? '',
|
||||||
expiresAt: token.expiresAt ?? 0,
|
expiresAt: (userConfig.expiresAt as number) ?? 0,
|
||||||
clientId: config.clientId,
|
clientId: serverConfig.clientId as string,
|
||||||
clientSecret: config.clientSecret,
|
clientSecret: serverConfig.clientSecret as string,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"@react-email/code-block": "^0.0.11",
|
"@react-email/code-block": "^0.0.11",
|
||||||
"@react-email/components": "^0.0.31",
|
"@react-email/components": "^0.0.31",
|
||||||
"@react-email/render": "^1.0.3",
|
"@react-email/render": "^1.0.3",
|
||||||
|
"officerdb": "workspace:*",
|
||||||
"react-email": "^3.0.4"
|
"react-email": "^3.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createTransport, type Transporter } from 'nodemailer';
|
import { createTransport, type Transporter } from 'nodemailer';
|
||||||
import { join } from 'node:path';
|
import { readServerSettings } from 'officerdb';
|
||||||
|
|
||||||
const { MAIL_TRANSPORT, DATA_PATH } = process.env;
|
const { MAIL_TRANSPORT } = process.env;
|
||||||
const dataPath = DATA_PATH ?? join(process.cwd(), 'data');
|
|
||||||
|
|
||||||
type SmtpConfig = {
|
type SmtpConfig = {
|
||||||
provider: 'resend' | 'smtp' | 'mailhog';
|
provider: 'resend' | 'smtp' | 'mailhog';
|
||||||
@@ -16,8 +15,6 @@ type SmtpConfig = {
|
|||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsPath = join(dataPath, 'server-settings', 'server-settings.json');
|
|
||||||
|
|
||||||
let cachedTransport: Transporter | null = null;
|
let cachedTransport: Transporter | null = null;
|
||||||
let cachedConfigHash: string | null = null;
|
let cachedConfigHash: string | null = null;
|
||||||
|
|
||||||
@@ -36,26 +33,23 @@ export type TransportResult = SmtpTransport | ResendTransport;
|
|||||||
|
|
||||||
export async function getTransport(): Promise<TransportResult> {
|
export async function getTransport(): Promise<TransportResult> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(settingsPath);
|
const settings = await readServerSettings();
|
||||||
if (await file.exists()) {
|
const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
||||||
const settings = await file.json();
|
if (smtp) {
|
||||||
const smtp: SmtpConfig | undefined = settings.smtp;
|
const from = `${smtp.fromName} <${smtp.fromEmail}>`;
|
||||||
if (smtp) {
|
|
||||||
const from = `${smtp.fromName} <${smtp.fromEmail}>`;
|
|
||||||
|
|
||||||
if (smtp.provider === 'resend') {
|
if (smtp.provider === 'resend') {
|
||||||
return { type: 'resend', apiKey: smtp.apiKey ?? '', from };
|
return { type: 'resend', apiKey: smtp.apiKey ?? '', from };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = JSON.stringify(smtp);
|
const hash = JSON.stringify(smtp);
|
||||||
if (cachedTransport && cachedConfigHash === hash) {
|
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 };
|
return { type: 'smtp', transport: cachedTransport, from };
|
||||||
}
|
}
|
||||||
|
const url = buildTransportUrl(smtp);
|
||||||
|
cachedTransport = createTransport(url);
|
||||||
|
cachedConfigHash = hash;
|
||||||
|
return { type: 'smtp', transport: cachedTransport, from };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to env var
|
// Fall through to env var
|
||||||
|
|||||||
Reference in New Issue
Block a user