feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

-109
View File
@@ -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 {
+54 -178
View File
@@ -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)`),
},
+54 -178
View File
@@ -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`),
},
-124
View File
@@ -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,
+4 -62
View File
@@ -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)
+66
View File
@@ -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 };
}
+29
View File
@@ -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();
+5
View File
@@ -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,
+63
View File
@@ -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,
+4
View File
@@ -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 = [];
+14 -1
View File
@@ -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),
});
}
-2
View File
@@ -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(