mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { emitInternalEvent } from "./internal-events.js";
|
||||
|
||||
export interface DeletedHost {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a host the user owns, with everything that hangs off it. Shared by
|
||||
* the host editor's delete route and ctx.hosts.delete, so a plugin deleting a
|
||||
* host leaves exactly what a person deleting it would. Null when the host
|
||||
* does not exist or is not the user's.
|
||||
*/
|
||||
export async function deleteOwnedHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<DeletedHost | null> {
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host) return null;
|
||||
|
||||
// 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(hostId);
|
||||
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
|
||||
await createCurrentHostRepository().deleteForUser(userId, hostId);
|
||||
|
||||
if (host.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"hosts",
|
||||
host.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
const name = host.name ?? host.ip;
|
||||
emitInternalEvent("host_deleted", userId, hostId, { name });
|
||||
|
||||
try {
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId, userId });
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to publish host deletion event", {
|
||||
operation: "host_delete",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return { id: hostId, name };
|
||||
}
|
||||
@@ -254,6 +254,35 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
: null;
|
||||
},
|
||||
|
||||
delete: async (hostId: number): Promise<boolean> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
if (
|
||||
!(await PermissionManager.getInstance().hasPermission(
|
||||
userId,
|
||||
"hosts.delete",
|
||||
))
|
||||
) {
|
||||
throw new Error("The acting user may not delete hosts");
|
||||
}
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
const deleted = await deleteOwnedHost(userId, hostId);
|
||||
await audit("hosts_delete", `host ${hostId}`, {
|
||||
success: deleted !== null,
|
||||
});
|
||||
return deleted !== null;
|
||||
} catch (error) {
|
||||
await audit("hosts_delete", `host ${hostId}`, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
listOwned: async (): Promise<PluginHostRecord[]> => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
|
||||
@@ -132,6 +132,11 @@ export function createPluginFetch(deps: Deps): PluginFetch {
|
||||
() => controller.abort(),
|
||||
Math.max(init.timeoutMs ?? 30_000, 1),
|
||||
);
|
||||
// The caller's signal keeps working after the headers arrive, so it
|
||||
// can stop a streamed body; the timeout only covers the wait for them.
|
||||
const onAbort = () => controller.abort();
|
||||
if (init.signal?.aborted) controller.abort();
|
||||
init.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
return await safeOutboundFetch(
|
||||
url,
|
||||
|
||||
@@ -181,6 +181,23 @@ export function createPluginContext(
|
||||
const logContext = { operation: `plugin:${pluginId}` };
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
// ctx.secrets rows live in plugin_settings under the "secret" scope, one
|
||||
// per user, so they go with the user and with the plugin.
|
||||
const secretsRepository = async () => {
|
||||
const { createCurrentPluginSettingsRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentPluginSettingsRepository();
|
||||
};
|
||||
const secretOwner = (): string => {
|
||||
const actor = getActor();
|
||||
if (!actor) {
|
||||
throw new Error(
|
||||
"ctx.secrets needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
return actor;
|
||||
};
|
||||
|
||||
const kvGet = guarded(
|
||||
manifest,
|
||||
"kv:own",
|
||||
@@ -537,6 +554,52 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
secrets: {
|
||||
get: async (key) => {
|
||||
await assertCapability(pluginId, "secrets:own", declared);
|
||||
const row = await (
|
||||
await secretsRepository()
|
||||
).get(pluginId, "secret", secretOwner(), key);
|
||||
if (!row?.value) return null;
|
||||
const { decryptSystemSecret } =
|
||||
await import("../utils/system-secret-crypto.js");
|
||||
return decryptSystemSecret(JSON.parse(row.value) as string);
|
||||
},
|
||||
|
||||
set: guarded(
|
||||
manifest,
|
||||
"secrets:own",
|
||||
async (key: string, value: string | null) => {
|
||||
const repository = await secretsRepository();
|
||||
const owner = secretOwner();
|
||||
if (value === null || value === "") {
|
||||
await repository.delete(pluginId, "secret", owner, key);
|
||||
return;
|
||||
}
|
||||
const { encryptSystemSecret } =
|
||||
await import("../utils/system-secret-crypto.js");
|
||||
await repository.set(
|
||||
pluginId,
|
||||
"secret",
|
||||
owner,
|
||||
key,
|
||||
JSON.stringify(await encryptSystemSecret(value)),
|
||||
true,
|
||||
);
|
||||
},
|
||||
{ action: "secret_set", details: () => "stored a secret" },
|
||||
),
|
||||
|
||||
delete: guarded(
|
||||
manifest,
|
||||
"secrets:own",
|
||||
async (key: string) => {
|
||||
await (
|
||||
await secretsRepository()
|
||||
).delete(pluginId, "secret", secretOwner(), key);
|
||||
},
|
||||
{ action: "secret_delete", details: () => "deleted a secret" },
|
||||
),
|
||||
|
||||
offer: (key, resolve) => {
|
||||
const entry = manifest.providesSecret?.find(
|
||||
(candidate) => candidate.key === key,
|
||||
|
||||
@@ -397,6 +397,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runDockerSettingsMigration } =
|
||||
await import("./utils/crypto-migration/docker-settings-migration.js");
|
||||
await runDockerSettingsMigration();
|
||||
|
||||
const { runAiSettingsMigration } =
|
||||
await import("./utils/crypto-migration/ai-settings-migration.js");
|
||||
await runAiSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -92,6 +92,16 @@ describe("user deletion", () => {
|
||||
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
|
||||
});
|
||||
|
||||
it("removes the user's ctx.secrets rows too", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("secret", "user-1");
|
||||
await addSetting("secret", "user-2");
|
||||
|
||||
await new UserRepository(context).delete("user-1");
|
||||
|
||||
expect(await remaining()).toEqual([{ scope: "secret", scopeId: "user-2" }]);
|
||||
});
|
||||
|
||||
it("leaves admin-scope settings alone", async () => {
|
||||
const context = await seed();
|
||||
await addSetting("admin", null);
|
||||
|
||||
@@ -25,6 +25,8 @@ const h = vi.hoisted(() => ({
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
userPermissions: new Set<string>(),
|
||||
deleted: [] as Array<{ userId: string; hostId: number }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -52,6 +54,8 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => h.access,
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
h.userPermissions.has(permission),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -147,6 +151,13 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
return hostId === 404 ? null : { id: hostId, name: "gone" };
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
@@ -174,6 +185,8 @@ beforeEach(() => {
|
||||
h.updateResult = null;
|
||||
h.users = [];
|
||||
h.roles = [];
|
||||
h.userPermissions = new Set();
|
||||
h.deleted = [];
|
||||
});
|
||||
|
||||
describe("ctx.hosts", () => {
|
||||
@@ -402,6 +415,45 @@ describe("ctx.hosts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.delete", () => {
|
||||
it("refuses without hosts:write and audits it", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_delete",
|
||||
"host 1",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an acting user without hosts.delete", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes the actor's own host through core's delete path", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.userPermissions = new Set(["hosts.delete"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.delete(7)).toBe(true);
|
||||
expect(await hosts.delete(404)).toBe(false);
|
||||
expect(h.deleted).toEqual([
|
||||
{ userId: "user-1", hostId: 7 },
|
||||
{ userId: "user-1", hostId: 404 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts session tracking and activity", () => {
|
||||
it("counts a live session through core and releases it", () => {
|
||||
h.sessions = [];
|
||||
|
||||
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -58,9 +59,10 @@ vi.mock("../../utils/notification-sender.js", () => ({
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
_init: RequestInit,
|
||||
init: RequestInit,
|
||||
allowlist: readonly string[],
|
||||
) => {
|
||||
state.lastSignal = init.signal ?? null;
|
||||
state.fetchCalls.push({ url, allowlist });
|
||||
return new Response("ok", { status: 200 });
|
||||
},
|
||||
@@ -181,4 +183,15 @@ describe("ctx.fetch", () => {
|
||||
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const controller = new AbortController();
|
||||
await ctx.fetch("https://example.com/stream", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(state.lastSignal?.aborted).toBe(false);
|
||||
controller.abort();
|
||||
expect(state.lastSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* ctx.secrets.get/set/delete: a plugin's own per-user secrets. Gated on
|
||||
* secrets:own, scoped to the acting user, encrypted at rest, and the value
|
||||
* never reaches the audit log.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: new Map<string, { value: string | null; encrypted: boolean }>(),
|
||||
}));
|
||||
|
||||
const rowKey = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => `${pluginId}|${scope}|${scopeId}|${key}`;
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => {
|
||||
const row = state.rows.get(rowKey(pluginId, scope, scopeId, key));
|
||||
return row ? { ...row } : null;
|
||||
},
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.rows.set(rowKey(pluginId, scope, scopeId, key), {
|
||||
value,
|
||||
encrypted,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => state.rows.delete(rowKey(pluginId, scope, scopeId, key)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.rows.clear();
|
||||
});
|
||||
|
||||
describe("ctx.secrets store", () => {
|
||||
it("refuses every call without secrets:own", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await expect(ctx.secrets.get("k")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.delete("k")).rejects.toThrow(/secrets:own/);
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_delete",
|
||||
success: false,
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("stores encrypted, per user, and never audits the value", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
|
||||
await runAsActor("alice", "request", () =>
|
||||
ctx.secrets.set("provider:1", "sk-alice"),
|
||||
);
|
||||
|
||||
expect(state.rows.get("demo|secret|alice|provider:1")).toEqual({
|
||||
value: JSON.stringify("sysenc:sk-alice"),
|
||||
encrypted: true,
|
||||
});
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBe("sk-alice");
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_set",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("sk-alice");
|
||||
});
|
||||
|
||||
it("clears on null and on delete", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await ctx.secrets.set("a", "1");
|
||||
await ctx.secrets.set("b", "2");
|
||||
await ctx.secrets.set("a", null);
|
||||
await ctx.secrets.delete("b");
|
||||
expect(await ctx.secrets.get("a")).toBeNull();
|
||||
expect(await ctx.secrets.get("b")).toBeNull();
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* The AI settings migration.
|
||||
*
|
||||
* An upgrade must be lossless: the admin switch, the private endpoint list,
|
||||
* every user's opt-in, every host's switch and every provider key have to
|
||||
* reach the ai plugin, keys encrypted and gone from the table. Running it
|
||||
* twice must not duplicate or clobber anything.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pluginRows: [] as Row[],
|
||||
coreSettings: new Map<string, string>(),
|
||||
preferences: [] as Array<Record<string, unknown>>,
|
||||
hosts: [] as Array<Record<string, unknown>>,
|
||||
providers: new Map<string, Array<Record<string, unknown>>>(),
|
||||
pluginInstalled: true,
|
||||
deks: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "ai" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
state.pluginRows.find(
|
||||
(row) =>
|
||||
row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.pluginRows.push({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
/** The SQL text of a drizzle sql`` query, identifiers included. */
|
||||
function text(query: { queryChunks: unknown[] }): string {
|
||||
return query.queryChunks
|
||||
.map((chunk) => {
|
||||
if (typeof chunk === "string") return chunk;
|
||||
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
|
||||
if (Array.isArray(value.value)) return value.value.join("");
|
||||
// sql.identifier() is a Name chunk carrying the table name.
|
||||
if (typeof value.value === "string") return value.value;
|
||||
if (typeof value.name === "string") return value.name;
|
||||
return String(chunk);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
selectRows: async (query: { queryChunks: unknown[] }) => {
|
||||
const sqlText = text(query);
|
||||
if (sqlText.includes("FROM user_preferences")) return state.preferences;
|
||||
if (sqlText.includes("FROM ssh_data")) return state.hosts;
|
||||
for (const [table, rows] of state.providers) {
|
||||
if (new RegExp(`FROM ${table} `).test(sqlText)) {
|
||||
return rows.filter((row) => row.api_key);
|
||||
}
|
||||
}
|
||||
throw new Error(`no such table in: ${sqlText}`);
|
||||
},
|
||||
runStatement: async (query: { queryChunks: unknown[] }) => {
|
||||
const sqlText = text(query);
|
||||
for (const [table, rows] of state.providers) {
|
||||
if (!sqlText.includes(`UPDATE ${table} `)) continue;
|
||||
const id = query.queryChunks.find((chunk) => typeof chunk === "number");
|
||||
for (const row of rows) if (row.id === id) row.api_key = null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) =>
|
||||
state.deks.has(userId) ? Buffer.from(userId) : null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/lazy-field-encryption.js", () => ({
|
||||
LazyFieldEncryption: {
|
||||
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
|
||||
// legacy plaintext value.
|
||||
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
allowlistToText,
|
||||
runAiSettingsMigration,
|
||||
} from "../../utils/crypto-migration/ai-settings-migration.js";
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = state.pluginRows.find(
|
||||
(entry) =>
|
||||
entry.scope === scope && entry.scopeId === scopeId && entry.key === key,
|
||||
);
|
||||
return row ? JSON.parse(row.value ?? "null") : undefined;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.pluginRows = [];
|
||||
state.coreSettings = new Map([
|
||||
["ai_globally_enabled", "true"],
|
||||
["ai_private_endpoint_allowlist", '["localhost", "ollama.lan"]'],
|
||||
]);
|
||||
state.preferences = [
|
||||
{ user_id: "u1", ai_assistant_enabled: 1, ai_read_only_commands: 0 },
|
||||
{ user_id: "u2", ai_assistant_enabled: null, ai_read_only_commands: null },
|
||||
];
|
||||
state.hosts = [
|
||||
{ id: 5, enable_ai_assistant: 1 },
|
||||
{ id: 6, enable_ai_assistant: 0 },
|
||||
];
|
||||
state.providers = new Map([
|
||||
[
|
||||
"ai_providers",
|
||||
[
|
||||
{ id: 3, user_id: "u1", api_key: "enc:sk-one" },
|
||||
{ id: 4, user_id: "u2", api_key: "sk-plain" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
state.pluginInstalled = true;
|
||||
state.deks = new Set(["u1", "u2"]);
|
||||
});
|
||||
|
||||
describe("allowlistToText", () => {
|
||||
it("turns the stored JSON array into one host per line", () => {
|
||||
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
|
||||
expect(allowlistToText("[]")).toBe("");
|
||||
expect(allowlistToText("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAiSettingsMigration", () => {
|
||||
it("moves admin, user and host settings", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(true);
|
||||
expect(value("admin", null, "privateEndpoints")).toBe(
|
||||
"localhost\nollama.lan",
|
||||
);
|
||||
expect(value("user", "u1", "enabled")).toBe(true);
|
||||
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
|
||||
// Never asked stays unset, which the plugin reads as not enabled.
|
||||
expect(value("user", "u2", "enabled")).toBeUndefined();
|
||||
expect(value("host", "5", "enableAiAssistant")).toBe(true);
|
||||
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
|
||||
await runAiSettingsMigration();
|
||||
|
||||
const secret = state.pluginRows.find(
|
||||
(row) => row.scope === "secret" && row.key === "provider:3",
|
||||
);
|
||||
expect(secret).toMatchObject({
|
||||
scopeId: "u1",
|
||||
encrypted: true,
|
||||
value: JSON.stringify("sysenc:sk-one"),
|
||||
});
|
||||
expect(value("secret", "u2", "provider:4")).toBe("sysenc:sk-plain");
|
||||
expect(
|
||||
state.providers.get("ai_providers")!.every((row) => row.api_key === null),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a key alone while its owner's data key is unavailable", async () => {
|
||||
state.deks = new Set(["u1"]);
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("secret", "u2", "provider:4")).toBeUndefined();
|
||||
const row = state.providers.get("ai_providers")!.find((r) => r.id === 4);
|
||||
expect(row?.api_key).toBe("sk-plain");
|
||||
});
|
||||
|
||||
it("reads the adopted table once the plugin has renamed it", async () => {
|
||||
state.providers = new Map([
|
||||
["p_ai_providers", [{ id: 8, user_id: "u1", api_key: "enc:sk-late" }]],
|
||||
]);
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("secret", "u1", "provider:8")).toBe("sysenc:sk-late");
|
||||
});
|
||||
|
||||
it("changes nothing on a second run", async () => {
|
||||
await runAiSettingsMigration();
|
||||
const first = JSON.stringify(state.pluginRows);
|
||||
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(first);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already has", async () => {
|
||||
state.pluginRows.push({
|
||||
pluginId: "ai",
|
||||
scope: "admin",
|
||||
scopeId: null,
|
||||
key: "globallyEnabled",
|
||||
value: "false",
|
||||
encrypted: false,
|
||||
});
|
||||
await runAiSettingsMigration();
|
||||
|
||||
expect(value("admin", null, "globallyEnabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
await runAiSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Moves the AI assistant's settings and provider keys into the ai plugin.
|
||||
*
|
||||
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
|
||||
* admin settings globallyEnabled and privateEndpoints (one host per line).
|
||||
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
|
||||
* each user's enabled and allowReadOnlyCommands.
|
||||
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
|
||||
* - Each provider's api_key, encrypted under its owner's data key, becomes a
|
||||
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
|
||||
* so no key material is left in the plugin's table.
|
||||
*
|
||||
* The old columns stay in the database, unused: core's drizzle migrations
|
||||
* run before this does, so dropping them there would lose the data first.
|
||||
* The providers table is ai_providers until the plugin adopts it and
|
||||
* p_ai_providers after, so both names are tried.
|
||||
*
|
||||
* Idempotent: nothing is written where the plugin already has a row, and a
|
||||
* key is only cleared once its copy exists. Safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
|
||||
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "ai";
|
||||
|
||||
export interface AiSettingsMigrationResult {
|
||||
admin: string[];
|
||||
users: number;
|
||||
hosts: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
type Flag = number | boolean | string | null | undefined;
|
||||
|
||||
function isTrue(value: Flag): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function isSet(value: Flag): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** The legacy JSON array as the textarea the plugin now reads. */
|
||||
export function allowlistToText(raw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
try {
|
||||
return await selectRows<T>(query);
|
||||
} catch {
|
||||
// A fresh install never had the table or column.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
|
||||
const result: AiSettingsMigrationResult = {
|
||||
admin: [],
|
||||
users: 0,
|
||||
hosts: 0,
|
||||
keys: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
|
||||
const setIfMissing = async (
|
||||
scope: "admin" | "user" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<boolean> => {
|
||||
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
|
||||
if (existing) return false;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const enabled = await settings.get("ai_globally_enabled");
|
||||
if (enabled !== null && enabled !== "") {
|
||||
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
|
||||
result.admin.push("globallyEnabled");
|
||||
}
|
||||
const allowlist = await settings.get("ai_private_endpoint_allowlist");
|
||||
const text = allowlist ? allowlistToText(allowlist) : null;
|
||||
if (text !== null) {
|
||||
if (await setIfMissing("admin", null, "privateEndpoints", text))
|
||||
result.admin.push("privateEndpoints");
|
||||
}
|
||||
} catch (error) {
|
||||
warn("admin settings", error);
|
||||
}
|
||||
|
||||
const users = await tryRows<{
|
||||
user_id: string;
|
||||
ai_assistant_enabled: Flag;
|
||||
ai_read_only_commands: Flag;
|
||||
}>(
|
||||
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
|
||||
);
|
||||
for (const row of users) {
|
||||
try {
|
||||
let moved = false;
|
||||
if (isSet(row.ai_assistant_enabled)) {
|
||||
moved =
|
||||
(await setIfMissing(
|
||||
"user",
|
||||
row.user_id,
|
||||
"enabled",
|
||||
isTrue(row.ai_assistant_enabled),
|
||||
)) || moved;
|
||||
}
|
||||
if (isSet(row.ai_read_only_commands)) {
|
||||
moved =
|
||||
(await setIfMissing(
|
||||
"user",
|
||||
row.user_id,
|
||||
"allowReadOnlyCommands",
|
||||
isTrue(row.ai_read_only_commands),
|
||||
)) || moved;
|
||||
}
|
||||
if (moved) result.users++;
|
||||
} catch (error) {
|
||||
warn("a user's settings", error);
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
|
||||
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
|
||||
);
|
||||
for (const row of hosts) {
|
||||
if (!isTrue(row.enable_ai_assistant)) continue;
|
||||
try {
|
||||
if (
|
||||
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
|
||||
) {
|
||||
result.hosts++;
|
||||
}
|
||||
} catch (error) {
|
||||
warn("a host's setting", error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of ["ai_providers", "p_ai_providers"]) {
|
||||
const providers = await tryRows<{
|
||||
id: number;
|
||||
user_id: string;
|
||||
api_key: string | null;
|
||||
}>(
|
||||
sql`SELECT id, user_id, api_key FROM ${sql.identifier(table)} WHERE api_key IS NOT NULL AND api_key <> ''`,
|
||||
);
|
||||
for (const row of providers) {
|
||||
try {
|
||||
const key = `provider:${row.id}`;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"secret",
|
||||
row.user_id,
|
||||
key,
|
||||
);
|
||||
if (!existing) {
|
||||
const dek = DataCrypto.getUserDataKey(row.user_id);
|
||||
if (!dek) continue;
|
||||
const plaintext = LazyFieldEncryption.safeGetFieldValue(
|
||||
row.api_key as string,
|
||||
dek,
|
||||
String(row.id),
|
||||
"apiKey",
|
||||
);
|
||||
if (!plaintext) continue;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"secret",
|
||||
row.user_id,
|
||||
key,
|
||||
JSON.stringify(await encryptSystemSecret(plaintext)),
|
||||
true,
|
||||
);
|
||||
result.keys++;
|
||||
}
|
||||
await runStatement(
|
||||
sql`UPDATE ${sql.identifier(table)} SET api_key = NULL WHERE id = ${row.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
warn("a provider key", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
result.admin.length > 0 ||
|
||||
result.users > 0 ||
|
||||
result.hosts > 0 ||
|
||||
result.keys > 0
|
||||
) {
|
||||
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
|
||||
operation: "ai_settings_migration",
|
||||
admin: result.admin.join(", "),
|
||||
users: result.users,
|
||||
hosts: result.hosts,
|
||||
keys: result.keys,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function warn(what: string, error: unknown): void {
|
||||
// A failed copy must not stop the backend; the next boot tries again.
|
||||
databaseLogger.warn(`AI settings migration failed for ${what}`, {
|
||||
operation: "ai_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
@@ -48,8 +48,6 @@ class FieldCrypto {
|
||||
opkssh_tokens: new Set(["sshCert", "privateKey"]),
|
||||
termix_identity_ca: new Set(["privateKey"]),
|
||||
vault_tokens: new Set(["sshCert", "privateKey"]),
|
||||
// Third-party AI provider keys are user credentials like any other.
|
||||
ai_providers: new Set(["apiKey"]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
|
||||
Reference in new issue
Block a user