mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -876,8 +876,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "ai_assistant_enabled", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "ai_read_only_commands", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
@@ -1089,11 +1087,6 @@ const migrateSchema = () => {
|
||||
"enable_terminal_toolbar",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_ai_assistant",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
@@ -2185,108 +2178,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- sync end ---
|
||||
|
||||
// --- ai begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM ai_providers LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
api_key_prefix TEXT,
|
||||
default_model TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, label)
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create ai_providers table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM ai_conversations LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
provider_id INTEGER,
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create ai_conversations table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM ai_messages LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_calls TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create ai_messages table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM ai_proposals LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_proposals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
applied_at TEXT,
|
||||
result_summary TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create ai_proposals table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- ai end ---
|
||||
|
||||
// Audit trails and session recordings used to be deleted along with the user
|
||||
// they referenced, which defeats the point of keeping them.
|
||||
migrateAuditRetention(sqlite);
|
||||
|
||||
@@ -123,35 +123,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "dismissed_alerts",
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
|
||||
// AI assistant.
|
||||
{
|
||||
name: "idx_ai_providers_user_label",
|
||||
table: "ai_providers",
|
||||
columns: "user_id, label",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_ai_conversations_user",
|
||||
table: "ai_conversations",
|
||||
columns: "user_id, updated_at",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_messages_conversation",
|
||||
table: "ai_messages",
|
||||
columns: "conversation_id, created_at",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_proposals_user",
|
||||
table: "ai_proposals",
|
||||
columns: "user_id, status",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_proposals_conversation",
|
||||
table: "ai_proposals",
|
||||
columns: "conversation_id",
|
||||
},
|
||||
];
|
||||
|
||||
interface IndexableDatabase {
|
||||
|
||||
@@ -62,10 +62,10 @@ export const ssoProviders = mysqlTable("sso_providers", {
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: int("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -83,7 +83,7 @@ export const sessions = mysqlTable(
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: int("sso_provider_id"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -108,7 +108,7 @@ export const trustedDevices = mysqlTable(
|
||||
deviceFingerprint: text("device_fingerprint").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -134,7 +134,7 @@ export const userExternalIdentities = mysqlTable(
|
||||
/** The provider's id for the user, at most 255 characters. */
|
||||
subject: varchar("subject", { length: 255 }).notNull(),
|
||||
email: text("email"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -186,7 +186,7 @@ export const webauthnCredentials = mysqlTable("webauthn_credentials", {
|
||||
backedUp: boolean("backed_up").notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
@@ -272,9 +272,6 @@ export const hosts = mysqlTable(
|
||||
enableTerminalToolbar: boolean("enable_terminal_toolbar")
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableAiAssistant: boolean("enable_ai_assistant")
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -351,10 +348,10 @@ export const hosts = mysqlTable(
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -420,10 +417,10 @@ export const sshCredentials = mysqlTable(
|
||||
usageCount: int("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -470,10 +467,10 @@ export const sshFolders = mysqlTable(
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: int("sort_order"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -525,7 +522,7 @@ export const hostAccess = mysqlTable(
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastAccessedAt: text("last_accessed_at"),
|
||||
@@ -555,10 +552,10 @@ export const sharedHostAuthOverrides = mysqlTable(
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -600,10 +597,10 @@ export const sharedHostSecrets = mysqlTable(
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
encryptedDomain: text("encrypted_domain"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -631,10 +628,10 @@ export const roles = mysqlTable("roles", {
|
||||
|
||||
permissions: text("permissions"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -725,7 +722,7 @@ export const opksshTokens = mysqlTable(
|
||||
issuer: text("issuer"),
|
||||
audience: text("audience"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -765,10 +762,10 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -790,7 +787,7 @@ export const vaultTokens = mysqlTable(
|
||||
sshCert: text("ssh_cert").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -812,7 +809,7 @@ export const apiKeys = mysqlTable(
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 }).notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
@@ -831,13 +828,13 @@ export const userOpenTabs = mysqlTable(
|
||||
hostId: int("host_id").references(() => hosts.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
label: text("label").notNull(),
|
||||
tabOrder: int("tab_order").notNull().default(0),
|
||||
backendSessionId: text("backend_session_id"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -868,19 +865,13 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
// null means the user has not been asked yet; the assistant stays hidden
|
||||
// until this is explicitly true and the admin global is on.
|
||||
aiAssistantEnabled: boolean("ai_assistant_enabled"),
|
||||
// Opt-in to letting the assistant run allowlisted read-only diagnostics
|
||||
// without a per-command approval click.
|
||||
aiReadOnlyCommands: boolean("ai_read_only_commands"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -892,7 +883,7 @@ export const hostSidebarPreferences = mysqlTable("host_sidebar_preferences", {
|
||||
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
|
||||
// plain JSON.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -906,7 +897,7 @@ export const credentialSidebarPreferences = mysqlTable(
|
||||
// JSON-encoded CredentialSidebarPreferences. No secrets in this blob,
|
||||
// same convention as hostSidebarPreferences.data.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -920,7 +911,7 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
// state). No secrets in this blob, same convention as
|
||||
// hostSidebarPreferences.data.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -930,14 +921,14 @@ export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: int("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -955,10 +946,10 @@ export const termixIdentities = mysqlTable("termix_identities", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
handle: varchar("handle", { length: 255 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -978,7 +969,7 @@ export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
|
||||
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
|
||||
keyType: text("key_type").notNull(),
|
||||
algorithm: text("algorithm").notNull(),
|
||||
label: varchar("label", { length: 255 }),
|
||||
label: text("label"),
|
||||
comment: text("comment"),
|
||||
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
|
||||
source: text("source").notNull().default("manual"),
|
||||
@@ -986,7 +977,7 @@ export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -1006,10 +997,10 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
validityDays: int("validity_days").notNull().default(90),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -1025,7 +1016,7 @@ export const notificationChannels = mysqlTable("notification_channels", {
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -1045,10 +1036,10 @@ export const homepageItems = mysqlTable(
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: int("folder_id"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1063,7 +1054,7 @@ export const homepageLayouts = mysqlTable("homepage_layouts", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
@@ -1088,121 +1079,6 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- ai begin ---
|
||||
/**
|
||||
* A user's connection to one AI provider. api_key is encrypted at rest via
|
||||
* FieldCrypto; it is never returned to the frontend, which only ever sees
|
||||
* api_key_prefix for display.
|
||||
*/
|
||||
export const aiProviders = mysqlTable(
|
||||
"ai_providers",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// ollama | anthropic | openai | gemini | openai_compatible
|
||||
providerType: text("provider_type").notNull(),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
// Required for ollama and openai_compatible, optional elsewhere.
|
||||
baseUrl: text("base_url"),
|
||||
apiKey: text("api_key"),
|
||||
// First few characters, kept in the clear so the UI can identify a key.
|
||||
apiKeyPrefix: text("api_key_prefix"),
|
||||
defaultModel: text("default_model"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiConversations = mysqlTable(
|
||||
"ai_conversations",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
providerId: int("provider_id"),
|
||||
model: text("model"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiMessages = mysqlTable(
|
||||
"ai_messages",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
conversationId: int("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
// user | assistant | tool
|
||||
role: text("role").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
// Serialized tool calls and their results for this turn.
|
||||
toolCalls: text("tool_calls"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_messages_conversation").on(
|
||||
table.conversationId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A change the assistant wants to make. Nothing here has been applied: the
|
||||
* payload is re-validated against the tool schema at apply time and only then
|
||||
* dispatched through the same repository logic a human action uses.
|
||||
*/
|
||||
export const aiProposals = mysqlTable(
|
||||
"ai_proposals",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
conversationId: int("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The propose_* tool name that produced this.
|
||||
kind: text("kind").notNull(),
|
||||
summary: text("summary"),
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
// pending | applied | rejected | expired
|
||||
status: varchar("status", { length: 255 }).notNull().default("pending"),
|
||||
appliedAt: text("applied_at"),
|
||||
resultSummary: text("result_summary"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_proposals_user").on(table.userId, table.status),
|
||||
index("idx_ai_proposals_conversation").on(table.conversationId),
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
@@ -1227,10 +1103,10 @@ export const secretSources = mysqlTable(
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1265,7 +1141,7 @@ export const credentialAccess = mysqlTable(
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1303,10 +1179,10 @@ export const sharedCredentialSecrets = mysqlTable(
|
||||
publicKey: text("public_key"),
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1355,7 +1231,7 @@ export const folderAccess = mysqlTable(
|
||||
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1388,7 +1264,7 @@ export const plugins = mysqlTable(
|
||||
installedAt: text("installed_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
autoUpdate: boolean("auto_update")
|
||||
@@ -1456,7 +1332,7 @@ export const pluginInstallCounts = mysqlTable(
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: int("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1482,7 +1358,7 @@ export const pluginStorage = mysqlTable(
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
@@ -1519,7 +1395,7 @@ export const pluginSettings = mysqlTable(
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
|
||||
@@ -63,10 +63,10 @@ export const ssoProviders = pgTable("sso_providers", {
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export const sessions = pgTable(
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -109,7 +109,7 @@ export const trustedDevices = pgTable(
|
||||
deviceFingerprint: text("device_fingerprint").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -135,7 +135,7 @@ export const userExternalIdentities = pgTable(
|
||||
/** The provider's id for the user, at most 255 characters. */
|
||||
subject: varchar("subject", { length: 255 }).notNull(),
|
||||
email: text("email"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -187,7 +187,7 @@ export const webauthnCredentials = pgTable("webauthn_credentials", {
|
||||
backedUp: boolean("backed_up").notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
@@ -273,9 +273,6 @@ export const hosts = pgTable(
|
||||
enableTerminalToolbar: boolean("enable_terminal_toolbar")
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableAiAssistant: boolean("enable_ai_assistant")
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -352,10 +349,10 @@ export const hosts = pgTable(
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -421,10 +418,10 @@ export const sshCredentials = pgTable(
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -471,10 +468,10 @@ export const sshFolders = pgTable(
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -526,7 +523,7 @@ export const hostAccess = pgTable(
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastAccessedAt: text("last_accessed_at"),
|
||||
@@ -556,10 +553,10 @@ export const sharedHostAuthOverrides = pgTable(
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -601,10 +598,10 @@ export const sharedHostSecrets = pgTable(
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
encryptedDomain: text("encrypted_domain"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -632,10 +629,10 @@ export const roles = pgTable("roles", {
|
||||
|
||||
permissions: text("permissions"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -726,7 +723,7 @@ export const opksshTokens = pgTable(
|
||||
issuer: text("issuer"),
|
||||
audience: text("audience"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -766,10 +763,10 @@ export const vaultProfiles = pgTable("vault_profiles", {
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -791,7 +788,7 @@ export const vaultTokens = pgTable(
|
||||
sshCert: text("ssh_cert").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||
@@ -813,7 +810,7 @@ export const apiKeys = pgTable(
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
createdAt: varchar("created_at", { length: 255 }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
@@ -832,13 +829,13 @@ export const userOpenTabs = pgTable(
|
||||
hostId: integer("host_id").references(() => hosts.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
label: text("label").notNull(),
|
||||
tabOrder: integer("tab_order").notNull().default(0),
|
||||
backendSessionId: text("backend_session_id"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -869,19 +866,13 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
disableUpdateCheck: boolean("disable_update_check"),
|
||||
confirmTabClose: boolean("confirm_tab_close"),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
// null means the user has not been asked yet; the assistant stays hidden
|
||||
// until this is explicitly true and the admin global is on.
|
||||
aiAssistantEnabled: boolean("ai_assistant_enabled"),
|
||||
// Opt-in to letting the assistant run allowlisted read-only diagnostics
|
||||
// without a per-command approval click.
|
||||
aiReadOnlyCommands: boolean("ai_read_only_commands"),
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
terminalDefaults: text("terminal_defaults"),
|
||||
terminalMacros: text("terminal_macros"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -893,7 +884,7 @@ export const hostSidebarPreferences = pgTable("host_sidebar_preferences", {
|
||||
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
|
||||
// plain JSON.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -907,7 +898,7 @@ export const credentialSidebarPreferences = pgTable(
|
||||
// JSON-encoded CredentialSidebarPreferences. No secrets in this blob,
|
||||
// same convention as hostSidebarPreferences.data.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -921,7 +912,7 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
// state). No secrets in this blob, same convention as
|
||||
// hostSidebarPreferences.data.
|
||||
data: text("data").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -931,14 +922,14 @@ export const dashboardServiceLinks = pgTable("dashboard_service_links", {
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -956,10 +947,10 @@ export const termixIdentities = pgTable("termix_identities", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
handle: varchar("handle", { length: 255 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -979,7 +970,7 @@ export const termixIdentityKeys = pgTable("termix_identity_keys", {
|
||||
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
|
||||
keyType: text("key_type").notNull(),
|
||||
algorithm: text("algorithm").notNull(),
|
||||
label: varchar("label", { length: 255 }),
|
||||
label: text("label"),
|
||||
comment: text("comment"),
|
||||
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
|
||||
source: text("source").notNull().default("manual"),
|
||||
@@ -987,7 +978,7 @@ export const termixIdentityKeys = pgTable("termix_identity_keys", {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1007,10 +998,10 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
validityDays: integer("validity_days").notNull().default(90),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1026,7 +1017,7 @@ export const notificationChannels = pgTable("notification_channels", {
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1046,10 +1037,10 @@ export const homepageItems = pgTable(
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1064,7 +1055,7 @@ export const homepageLayouts = pgTable("homepage_layouts", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -1089,121 +1080,6 @@ export const syncTombstones = pgTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- ai begin ---
|
||||
/**
|
||||
* A user's connection to one AI provider. api_key is encrypted at rest via
|
||||
* FieldCrypto; it is never returned to the frontend, which only ever sees
|
||||
* api_key_prefix for display.
|
||||
*/
|
||||
export const aiProviders = pgTable(
|
||||
"ai_providers",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// ollama | anthropic | openai | gemini | openai_compatible
|
||||
providerType: text("provider_type").notNull(),
|
||||
label: varchar("label", { length: 255 }).notNull(),
|
||||
// Required for ollama and openai_compatible, optional elsewhere.
|
||||
baseUrl: text("base_url"),
|
||||
apiKey: text("api_key"),
|
||||
// First few characters, kept in the clear so the UI can identify a key.
|
||||
apiKeyPrefix: text("api_key_prefix"),
|
||||
defaultModel: text("default_model"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiConversations = pgTable(
|
||||
"ai_conversations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
providerId: integer("provider_id"),
|
||||
model: text("model"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiMessages = pgTable(
|
||||
"ai_messages",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
conversationId: integer("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
// user | assistant | tool
|
||||
role: text("role").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
// Serialized tool calls and their results for this turn.
|
||||
toolCalls: text("tool_calls"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_messages_conversation").on(
|
||||
table.conversationId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A change the assistant wants to make. Nothing here has been applied: the
|
||||
* payload is re-validated against the tool schema at apply time and only then
|
||||
* dispatched through the same repository logic a human action uses.
|
||||
*/
|
||||
export const aiProposals = pgTable(
|
||||
"ai_proposals",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
conversationId: integer("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The propose_* tool name that produced this.
|
||||
kind: text("kind").notNull(),
|
||||
summary: text("summary"),
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
// pending | applied | rejected | expired
|
||||
status: varchar("status", { length: 255 }).notNull().default("pending"),
|
||||
appliedAt: text("applied_at"),
|
||||
resultSummary: text("result_summary"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_proposals_user").on(table.userId, table.status),
|
||||
index("idx_ai_proposals_conversation").on(table.conversationId),
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
@@ -1228,10 +1104,10 @@ export const secretSources = pgTable(
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1266,7 +1142,7 @@ export const credentialAccess = pgTable(
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1304,10 +1180,10 @@ export const sharedCredentialSecrets = pgTable(
|
||||
publicKey: text("public_key"),
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1356,7 +1232,7 @@ export const folderAccess = pgTable(
|
||||
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1389,7 +1265,7 @@ export const plugins = pgTable(
|
||||
installedAt: text("installed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
autoUpdate: boolean("auto_update")
|
||||
@@ -1457,7 +1333,7 @@ export const pluginInstallCounts = pgTable(
|
||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
source: text("source").notNull().default("aggregate-telemetry"),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1483,7 +1359,7 @@ export const pluginStorage = pgTable(
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
@@ -1520,7 +1396,7 @@ export const pluginSettings = pgTable(
|
||||
encrypted: boolean("encrypted")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
|
||||
@@ -265,9 +265,6 @@ export const hosts = sqliteTable(
|
||||
enableTerminalToolbar: integer("enable_terminal_toolbar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableAiAssistant: integer("enable_ai_assistant", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -865,12 +862,6 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
// null means the user has not been asked yet; the assistant stays hidden
|
||||
// until this is explicitly true and the admin global is on.
|
||||
aiAssistantEnabled: integer("ai_assistant_enabled", { mode: "boolean" }),
|
||||
// Opt-in to letting the assistant run allowlisted read-only diagnostics
|
||||
// without a per-command approval click.
|
||||
aiReadOnlyCommands: integer("ai_read_only_commands", { mode: "boolean" }),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
@@ -1085,121 +1076,6 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
// --- ai begin ---
|
||||
/**
|
||||
* A user's connection to one AI provider. api_key is encrypted at rest via
|
||||
* FieldCrypto; it is never returned to the frontend, which only ever sees
|
||||
* api_key_prefix for display.
|
||||
*/
|
||||
export const aiProviders = sqliteTable(
|
||||
"ai_providers",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// ollama | anthropic | openai | gemini | openai_compatible
|
||||
providerType: text("provider_type").notNull(),
|
||||
label: text("label").notNull(),
|
||||
// Required for ollama and openai_compatible, optional elsewhere.
|
||||
baseUrl: text("base_url"),
|
||||
apiKey: text("api_key", { length: 8192 }),
|
||||
// First few characters, kept in the clear so the UI can identify a key.
|
||||
apiKeyPrefix: text("api_key_prefix"),
|
||||
defaultModel: text("default_model"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiConversations = sqliteTable(
|
||||
"ai_conversations",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
providerId: integer("provider_id"),
|
||||
model: text("model"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const aiMessages = sqliteTable(
|
||||
"ai_messages",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
conversationId: integer("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
// user | assistant | tool
|
||||
role: text("role").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
// Serialized tool calls and their results for this turn.
|
||||
toolCalls: text("tool_calls"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_messages_conversation").on(
|
||||
table.conversationId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A change the assistant wants to make. Nothing here has been applied: the
|
||||
* payload is re-validated against the tool schema at apply time and only then
|
||||
* dispatched through the same repository logic a human action uses.
|
||||
*/
|
||||
export const aiProposals = sqliteTable(
|
||||
"ai_proposals",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
conversationId: integer("conversation_id")
|
||||
.notNull()
|
||||
.references(() => aiConversations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The propose_* tool name that produced this.
|
||||
kind: text("kind").notNull(),
|
||||
summary: text("summary"),
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
// pending | applied | rejected | expired
|
||||
status: text("status").notNull().default("pending"),
|
||||
appliedAt: text("applied_at"),
|
||||
resultSummary: text("result_summary"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_ai_proposals_user").on(table.userId, table.status),
|
||||
index("idx_ai_proposals_conversation").on(table.conversationId),
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
aiConversations,
|
||||
aiMessages,
|
||||
aiProposals,
|
||||
aiProviders,
|
||||
} from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { formatSqlTimestamp } from "./sql-timestamp.js";
|
||||
|
||||
const now = (): string => formatSqlTimestamp(new Date());
|
||||
|
||||
export type AiProviderRecord = typeof aiProviders.$inferSelect;
|
||||
export type AiConversationRecord = typeof aiConversations.$inferSelect;
|
||||
export type AiMessageRecord = typeof aiMessages.$inferSelect;
|
||||
export type AiProposalRecord = typeof aiProposals.$inferSelect;
|
||||
|
||||
export interface AiProviderInput {
|
||||
userId: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the first few characters so the UI can tell two keys apart without
|
||||
* ever receiving the key itself.
|
||||
*/
|
||||
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
|
||||
if (!apiKey) return null;
|
||||
return apiKey.slice(0, 6);
|
||||
}
|
||||
|
||||
export class AiRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Provider API keys are field-encrypted like any other credential. The key is
|
||||
* derived from the row id, which does not exist until after the insert, so a
|
||||
* new provider is written once and then re-encrypted in place with its real
|
||||
* id -- the same approach NotificationChannelRepository uses for channel configs.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptApiKey(
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return apiKey;
|
||||
return DataCrypto.encryptRecord(
|
||||
"ai_providers",
|
||||
{ id: recordId, apiKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).apiKey;
|
||||
}
|
||||
|
||||
private decryptApiKey(
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return apiKey;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"ai_providers",
|
||||
{ id: recordId, apiKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).apiKey;
|
||||
} catch {
|
||||
// Rows written before encryption was enabled are still plaintext.
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// --- providers ---
|
||||
|
||||
/** Never includes the key material; callers get the masked prefix only. */
|
||||
async listProviders(userId: string): Promise<AiProviderRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.userId, userId))
|
||||
.orderBy(aiProviders.id);
|
||||
|
||||
return rows.map((row) => ({ ...row, apiKey: null }));
|
||||
}
|
||||
|
||||
async findProvider(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
return { ...rows[0], apiKey: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one path that returns usable key material. Only the provider adapters
|
||||
* call this, immediately before an outbound request.
|
||||
*/
|
||||
async findProviderWithSecret(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (!row.apiKey) return row;
|
||||
return {
|
||||
...row,
|
||||
apiKey: this.decryptApiKey(row.apiKey, userId, row.id),
|
||||
};
|
||||
}
|
||||
|
||||
async createProvider(input: AiProviderInput): Promise<AiProviderRecord> {
|
||||
const [created] = await insertReturning(this.context, aiProviders, {
|
||||
userId: input.userId,
|
||||
providerType: input.providerType,
|
||||
label: input.label,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
apiKeyPrefix: apiKeyPrefix(input.apiKey),
|
||||
defaultModel: input.defaultModel ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
});
|
||||
|
||||
if (input.apiKey) {
|
||||
const encrypted = this.encryptApiKey(
|
||||
input.apiKey,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.apiKey) {
|
||||
await this.context.drizzle
|
||||
.update(aiProviders)
|
||||
.set({ apiKey: encrypted })
|
||||
.where(eq(aiProviders.id, created.id));
|
||||
}
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
return { ...created, apiKey: null };
|
||||
}
|
||||
|
||||
async updateProvider(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: Partial<Omit<AiProviderInput, "userId">>,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const existing = await this.findProvider(id, userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: now() };
|
||||
if (input.providerType !== undefined)
|
||||
updates.providerType = input.providerType;
|
||||
if (input.label !== undefined) updates.label = input.label;
|
||||
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
|
||||
if (input.defaultModel !== undefined)
|
||||
updates.defaultModel = input.defaultModel;
|
||||
if (input.enabled !== undefined) updates.enabled = input.enabled;
|
||||
|
||||
// An empty string clears the key; undefined leaves it untouched.
|
||||
if (input.apiKey !== undefined) {
|
||||
if (input.apiKey) {
|
||||
updates.apiKey = this.encryptApiKey(input.apiKey, userId, id);
|
||||
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
|
||||
} else {
|
||||
updates.apiKey = null;
|
||||
updates.apiKeyPrefix = null;
|
||||
}
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(aiProviders)
|
||||
.set(updates)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
|
||||
|
||||
await this.afterWrite();
|
||||
return this.findProvider(id, userId);
|
||||
}
|
||||
|
||||
async deleteProvider(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
|
||||
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// --- conversations ---
|
||||
|
||||
async listConversations(
|
||||
userId: string,
|
||||
limit = 50,
|
||||
): Promise<AiConversationRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiConversations)
|
||||
.where(eq(aiConversations.userId, userId))
|
||||
.orderBy(desc(aiConversations.updatedAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async findConversation(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiConversationRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiConversations)
|
||||
.where(
|
||||
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createConversation(input: {
|
||||
userId: string;
|
||||
title?: string | null;
|
||||
providerId?: number | null;
|
||||
model?: string | null;
|
||||
}): Promise<AiConversationRecord> {
|
||||
const [created] = await insertReturning(this.context, aiConversations, {
|
||||
userId: input.userId,
|
||||
title: input.title ?? null,
|
||||
providerId: input.providerId ?? null,
|
||||
model: input.model ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async touchConversation(id: number, title?: string | null): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: now() };
|
||||
if (title) updates.title = title;
|
||||
await this.context.drizzle
|
||||
.update(aiConversations)
|
||||
.set(updates)
|
||||
.where(eq(aiConversations.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteConversation(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(aiConversations)
|
||||
.where(
|
||||
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
|
||||
);
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// --- messages ---
|
||||
|
||||
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiMessages)
|
||||
.where(eq(aiMessages.conversationId, conversationId))
|
||||
.orderBy(aiMessages.id);
|
||||
}
|
||||
|
||||
async appendMessage(input: {
|
||||
conversationId: number;
|
||||
role: string;
|
||||
content: string;
|
||||
toolCalls?: string | null;
|
||||
}): Promise<AiMessageRecord> {
|
||||
const [created] = await insertReturning(this.context, aiMessages, {
|
||||
conversationId: input.conversationId,
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
toolCalls: input.toolCalls ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
// --- proposals ---
|
||||
|
||||
async listProposals(
|
||||
userId: string,
|
||||
conversationId?: number,
|
||||
): Promise<AiProposalRecord[]> {
|
||||
const where = conversationId
|
||||
? and(
|
||||
eq(aiProposals.userId, userId),
|
||||
eq(aiProposals.conversationId, conversationId),
|
||||
)
|
||||
: eq(aiProposals.userId, userId);
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiProposals)
|
||||
.where(where)
|
||||
.orderBy(desc(aiProposals.id));
|
||||
}
|
||||
|
||||
async findProposal(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProposalRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProposals)
|
||||
.where(and(eq(aiProposals.id, id), eq(aiProposals.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createProposal(input: {
|
||||
conversationId: number;
|
||||
userId: string;
|
||||
kind: string;
|
||||
summary?: string | null;
|
||||
payload: string;
|
||||
}): Promise<AiProposalRecord> {
|
||||
const [created] = await insertReturning(this.context, aiProposals, {
|
||||
conversationId: input.conversationId,
|
||||
userId: input.userId,
|
||||
kind: input.kind,
|
||||
summary: input.summary ?? null,
|
||||
payload: input.payload,
|
||||
status: "pending",
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async setProposalStatus(
|
||||
id: number,
|
||||
userId: string,
|
||||
status: "applied" | "rejected" | "expired",
|
||||
resultSummary?: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(aiProposals)
|
||||
.set({
|
||||
status,
|
||||
appliedAt: status === "applied" ? now() : null,
|
||||
resultSummary: resultSummary ?? null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(aiProposals.id, id),
|
||||
eq(aiProposals.userId, userId),
|
||||
eq(aiProposals.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
const updated = rowsAffected(result) > 0;
|
||||
if (updated) await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
// --- account deletion ---
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
// ai_messages and ai_proposals cascade from ai_conversations.
|
||||
await this.context.drizzle
|
||||
.delete(aiConversations)
|
||||
.where(eq(aiConversations.userId, userId));
|
||||
await this.context.drizzle
|
||||
.delete(aiProviders)
|
||||
.where(eq(aiProviders.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { AiRepository } from "./ai-repository.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
@@ -140,13 +139,6 @@ export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialR
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAiRepository(): AiRepository {
|
||||
return new AiRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("ai_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
|
||||
return new NotificationChannelRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -6,7 +6,11 @@ import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||
|
||||
export type PluginSettingsScope = "admin" | "user" | "host";
|
||||
/**
|
||||
* "secret" holds ctx.secrets values, one row per user and key. The settings
|
||||
* service is driven by manifest fields, so it never reads them.
|
||||
*/
|
||||
export type PluginSettingsScope = "admin" | "user" | "host" | "secret";
|
||||
|
||||
/**
|
||||
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||
|
||||
@@ -205,7 +205,10 @@ export class UserRepository {
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
|
||||
and(
|
||||
inArray(pluginSettings.scope, ["user", "secret"]),
|
||||
eq(pluginSettings.scopeId, id),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
@@ -73,7 +72,6 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAiRepository().deleteByUserId(userId);
|
||||
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
|
||||
@@ -288,8 +288,6 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||
if (typeof updates.enableTerminalToolbar === "boolean")
|
||||
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
|
||||
if (typeof updates.enableAiAssistant === "boolean")
|
||||
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
|
||||
if (typeof updates.statusCheckEnabled === "boolean")
|
||||
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
|
||||
|
||||
@@ -751,7 +749,6 @@ export function registerHostBulkRoutes(
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: hostData.enableAiAssistant || false,
|
||||
enableCommandHistory: hostData.enableCommandHistory !== false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
@@ -1012,7 +1009,6 @@ export function registerHostBulkRoutes(
|
||||
enableFileManager: true,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
enableAiAssistant: false,
|
||||
showTerminalInSidebar: 0,
|
||||
showFileManagerInSidebar: 0,
|
||||
showTunnelInSidebar: 0,
|
||||
|
||||
@@ -178,7 +178,6 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
enableTerminalToolbar?: unknown;
|
||||
enableAiAssistant?: unknown;
|
||||
enableCommandHistory?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
@@ -340,7 +339,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"enableFileManager",
|
||||
"enableTmuxMonitor",
|
||||
"enableTerminalToolbar",
|
||||
"enableAiAssistant",
|
||||
"showTerminalInSidebar",
|
||||
"showFileManagerInSidebar",
|
||||
"showTunnelInSidebar",
|
||||
@@ -436,7 +434,6 @@ export function transformHostResponse(
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: !!host.enableAiAssistant,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
|
||||
@@ -15,17 +15,15 @@ import {
|
||||
pickResolvedUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
||||
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
@@ -202,7 +200,6 @@ router.post(
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
enableAiAssistant,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -332,7 +329,6 @@ router.post(
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -756,7 +752,6 @@ router.post(
|
||||
enableWebUi: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
enableAiAssistant: false,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
@@ -886,7 +881,6 @@ router.put(
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
enableAiAssistant,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -1017,7 +1011,6 @@ router.put(
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
enableAiAssistant: enableAiAssistant ? 1 : 0,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -2084,7 +2077,6 @@ router.get(
|
||||
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: !!resolvedHost.enableAiAssistant,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -2241,7 +2233,6 @@ router.get(
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar:
|
||||
resolvedHost.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: !!resolvedHost.enableAiAssistant,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -2418,13 +2409,9 @@ router.delete(
|
||||
hostId: parseInt(hostId),
|
||||
});
|
||||
try {
|
||||
const hostToDelete =
|
||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
||||
Number(hostId),
|
||||
userId,
|
||||
);
|
||||
const deleted = await deleteOwnedHost(userId, Number(hostId));
|
||||
|
||||
if (!hostToDelete) {
|
||||
if (!deleted) {
|
||||
sshLogger.warn("SSH host not found for deletion", {
|
||||
operation: "host_delete",
|
||||
hostId: parseInt(hostId),
|
||||
@@ -2433,34 +2420,6 @@ router.delete(
|
||||
return res.status(404).json({ error: "SSH host not found" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
// file manager recent/pinned/shortcuts, transfer_recent, command
|
||||
// history and session recordings cascade on the host's refHost()
|
||||
// foreign key, as the file-manager, ssh-terminal and
|
||||
// session-recording plugins' adopted tables.
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostId(
|
||||
numericHostId,
|
||||
);
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByHostId(
|
||||
numericHostId,
|
||||
);
|
||||
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
|
||||
numericHostId,
|
||||
);
|
||||
|
||||
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
|
||||
if (hostToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"hosts",
|
||||
hostToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.success("SSH host deleted", {
|
||||
operation: "host_delete_success",
|
||||
userId,
|
||||
@@ -2474,29 +2433,12 @@ router.delete(
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: hostId,
|
||||
resourceName: hostToDelete.name ?? hostToDelete.ip,
|
||||
resourceName: deleted.name,
|
||||
ipAddress: dhIp,
|
||||
userAgent: dhUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
emitInternalEvent("host_deleted", userId, numericHostId, {
|
||||
name: hostToDelete.name ?? hostToDelete.ip,
|
||||
});
|
||||
|
||||
try {
|
||||
pluginEvents.emit(TOPICS.hostDeleted, {
|
||||
hostId: numericHostId,
|
||||
userId,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to publish host deletion event", {
|
||||
operation: "host_delete",
|
||||
hostId: numericHostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "SSH host deleted" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete SSH host from database", err, {
|
||||
|
||||
@@ -32,8 +32,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
||||
confirmTabClose: row?.confirmTabClose ?? null,
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
aiAssistantEnabled: row?.aiAssistantEnabled ?? null,
|
||||
aiReadOnlyCommands: row?.aiReadOnlyCommands ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
@@ -227,8 +225,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
aiAssistantEnabled,
|
||||
aiReadOnlyCommands,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
@@ -249,8 +245,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
aiAssistantEnabled?: boolean | null;
|
||||
aiReadOnlyCommands?: boolean | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
@@ -392,8 +386,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
aiAssistantEnabled,
|
||||
aiReadOnlyCommands,
|
||||
};
|
||||
for (const [key, value] of Object.entries(boolFields)) {
|
||||
if (value !== undefined && value !== null && typeof value !== "boolean") {
|
||||
@@ -407,10 +399,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (language !== undefined) updates.language = language;
|
||||
if (storageMode !== undefined) updates.storageMode = storageMode;
|
||||
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
|
||||
if (aiAssistantEnabled !== undefined)
|
||||
updates.aiAssistantEnabled = aiAssistantEnabled;
|
||||
if (aiReadOnlyCommands !== undefined)
|
||||
updates.aiReadOnlyCommands = aiReadOnlyCommands;
|
||||
if (commandAutocomplete !== undefined)
|
||||
updates.commandAutocomplete = commandAutocomplete;
|
||||
if (commandPaletteEnabled !== undefined)
|
||||
|
||||
Reference in new issue
Block a user